From 7daf6cfad48260cb33f5f6b32f8f580a266d6f71 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:33:24 -0400 Subject: [PATCH 01/43] [PM-18794] Allow provider payment method (#5500) * Add PaymentSource to ProviderSubscriptionResponse * Add UpdatePaymentMethod to ProviderBillingController * Add GetTaxInformation to ProviderBillingController * Add VerifyBankAccount to ProviderBillingController * Add feature flag --- .../Billing/ProviderBillingService.cs | 13 +++ .../Controllers/ProviderBillingController.cs | 83 ++++++++++++++++++- .../Responses/ProviderSubscriptionResponse.cs | 9 +- .../Services/IProviderBillingService.cs | 11 +++ src/Core/Constants.cs | 1 + 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 294a926022..74cfc1f916 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -628,6 +628,19 @@ public class ProviderBillingService( } } + public async Task UpdatePaymentMethod( + Provider provider, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation) + { + await Task.WhenAll( + subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource), + subscriberService.UpdateTaxInformation(provider, taxInformation)); + + await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); + } + public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) { if (command.Configuration.Any(x => x.SeatsMinimum < 0)) diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 73c992040c..bb1fd7bb25 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,5 +1,6 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; +using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; @@ -20,6 +21,7 @@ namespace Bit.Api.Billing.Controllers; [Authorize("Application")] public class ProviderBillingController( ICurrentContext currentContext, + IFeatureService featureService, ILogger logger, IPricingClient pricingClient, IProviderBillingService providerBillingService, @@ -71,6 +73,65 @@ public class ProviderBillingController( "text/csv"); } + [HttpPut("payment-method")] + public async Task UpdatePaymentMethodAsync( + [FromRoute] Guid providerId, + [FromBody] UpdatePaymentMethodRequestBody requestBody) + { + var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); + + if (!allowProviderPaymentMethod) + { + return TypedResults.NotFound(); + } + + var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); + + if (provider == null) + { + return result; + } + + var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); + var taxInformation = requestBody.TaxInformation.ToDomain(); + + await providerBillingService.UpdatePaymentMethod( + provider, + tokenizedPaymentSource, + taxInformation); + + return TypedResults.Ok(); + } + + [HttpPost("payment-method/verify-bank-account")] + public async Task VerifyBankAccountAsync( + [FromRoute] Guid providerId, + [FromBody] VerifyBankAccountRequestBody requestBody) + { + var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); + + if (!allowProviderPaymentMethod) + { + return TypedResults.NotFound(); + } + + var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); + + if (provider == null) + { + return result; + } + + if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) + { + return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); + } + + await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode); + + return TypedResults.Ok(); + } + [HttpGet("subscription")] public async Task GetSubscriptionAsync([FromRoute] Guid providerId) { @@ -102,12 +163,32 @@ public class ProviderBillingController( var subscriptionSuspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription); + var paymentSource = await subscriberService.GetPaymentSource(provider); + var response = ProviderSubscriptionResponse.From( subscription, configuredProviderPlans, taxInformation, subscriptionSuspension, - provider); + provider, + paymentSource); + + return TypedResults.Ok(response); + } + + [HttpGet("tax-information")] + public async Task GetTaxInformationAsync([FromRoute] Guid providerId) + { + var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); + + if (provider == null) + { + return result; + } + + var taxInformation = await subscriberService.GetTaxInformation(provider); + + var response = TaxInformationResponse.From(taxInformation); return TypedResults.Ok(response); } diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index 34c3817e51..ea1479c9df 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -16,7 +16,8 @@ public record ProviderSubscriptionResponse( TaxInformation TaxInformation, DateTime? CancelAt, SubscriptionSuspension Suspension, - ProviderType ProviderType) + ProviderType ProviderType, + PaymentSource PaymentSource) { private const string _annualCadence = "Annual"; private const string _monthlyCadence = "Monthly"; @@ -26,7 +27,8 @@ public record ProviderSubscriptionResponse( ICollection providerPlans, TaxInformation taxInformation, SubscriptionSuspension subscriptionSuspension, - Provider provider) + Provider provider, + PaymentSource paymentSource) { var providerPlanResponses = providerPlans .Select(providerPlan => @@ -57,7 +59,8 @@ public record ProviderSubscriptionResponse( taxInformation, subscription.CancelAt, subscriptionSuspension, - provider.Type); + provider.Type, + paymentSource); } } diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index d6983da03e..64585f3361 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -95,5 +95,16 @@ public interface IProviderBillingService Task SetupSubscription( Provider provider); + /// + /// Updates the 's payment source and tax information and then sets their subscription's collection_method to be "charge_automatically". + /// + /// The to update the payment source and tax information for. + /// The tokenized payment source (ex. Credit Card) to attach to the . + /// The 's updated tax information. + Task UpdatePaymentMethod( + Provider provider, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation); + Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 72224319fb..9cbf6b788a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -175,6 +175,7 @@ public static class FeatureFlagKeys public const string WebPush = "web-push"; public const string AndroidImportLoginsFlow = "import-logins-flow"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; + public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public static List GetAllKeys() { From 488a9847ea15d977d28582bc44da110758e46ad1 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 14 Mar 2025 12:00:58 -0500 Subject: [PATCH 02/43] Partial for CommandResult (#5482) * Example of how a partial success/failure command result would look. * Fixed code. * Added Validator and ValidationResult * Moved errors into their own files. * Fixing tests * fixed import. * Forgot mock error. --- src/Api/Utilities/CommandResultExtensions.cs | 2 +- src/Core/AdminConsole/Errors/Error.cs | 3 + .../Errors/InsufficientPermissionsError.cs | 11 ++++ .../Errors/RecordNotFoundError.cs | 11 ++++ .../Shared/Validation/IValidator.cs | 6 ++ .../Shared/Validation/ValidationResult.cs | 15 +++++ src/Core/Models/Commands/CommandResult.cs | 27 ++++++--- .../AdminConsole/Shared/IValidatorTests.cs | 58 +++++++++++++++++++ .../Models/Commands/CommandResultTests.cs | 53 +++++++++++++++++ 9 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/Errors/Error.cs create mode 100644 src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs create mode 100644 src/Core/AdminConsole/Errors/RecordNotFoundError.cs create mode 100644 src/Core/AdminConsole/Shared/Validation/IValidator.cs create mode 100644 src/Core/AdminConsole/Shared/Validation/ValidationResult.cs create mode 100644 test/Core.Test/AdminConsole/Shared/IValidatorTests.cs create mode 100644 test/Core.Test/Models/Commands/CommandResultTests.cs diff --git a/src/Api/Utilities/CommandResultExtensions.cs b/src/Api/Utilities/CommandResultExtensions.cs index 39104db7ff..c7315a0fa0 100644 --- a/src/Api/Utilities/CommandResultExtensions.cs +++ b/src/Api/Utilities/CommandResultExtensions.cs @@ -12,7 +12,7 @@ public static class CommandResultExtensions NoRecordFoundFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound }, BadRequestFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, Failure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Success success => new ObjectResult(success.Data) { StatusCode = StatusCodes.Status200OK }, + Success success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK }, _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}") }; } diff --git a/src/Core/AdminConsole/Errors/Error.cs b/src/Core/AdminConsole/Errors/Error.cs new file mode 100644 index 0000000000..6c8eed41a4 --- /dev/null +++ b/src/Core/AdminConsole/Errors/Error.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record Error(string Message, T ErroredValue); diff --git a/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs b/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs new file mode 100644 index 0000000000..d04ceba7c9 --- /dev/null +++ b/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record InsufficientPermissionsError(string Message, T ErroredValue) : Error(Message, ErroredValue) +{ + public const string Code = "Insufficient Permissions"; + + public InsufficientPermissionsError(T ErroredValue) : this(Code, ErroredValue) + { + + } +} diff --git a/src/Core/AdminConsole/Errors/RecordNotFoundError.cs b/src/Core/AdminConsole/Errors/RecordNotFoundError.cs new file mode 100644 index 0000000000..25a169efe1 --- /dev/null +++ b/src/Core/AdminConsole/Errors/RecordNotFoundError.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record RecordNotFoundError(string Message, T ErroredValue) : Error(Message, ErroredValue) +{ + public const string Code = "Record Not Found"; + + public RecordNotFoundError(T ErroredValue) : this(Code, ErroredValue) + { + + } +} diff --git a/src/Core/AdminConsole/Shared/Validation/IValidator.cs b/src/Core/AdminConsole/Shared/Validation/IValidator.cs new file mode 100644 index 0000000000..d90386f00e --- /dev/null +++ b/src/Core/AdminConsole/Shared/Validation/IValidator.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Shared.Validation; + +public interface IValidator +{ + public Task> ValidateAsync(T value); +} diff --git a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs new file mode 100644 index 0000000000..e25103e701 --- /dev/null +++ b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.Shared.Validation; + +public abstract record ValidationResult; + +public record Valid : ValidationResult +{ + public T Value { get; init; } +} + +public record Invalid : ValidationResult +{ + public IEnumerable> Errors { get; init; } +} diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index ae14b7d2f9..a8ec772fc1 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -1,5 +1,7 @@ #nullable enable +using Bit.Core.AdminConsole.Errors; + namespace Bit.Core.Models.Commands; public class CommandResult(IEnumerable errors) @@ -9,7 +11,6 @@ public class CommandResult(IEnumerable errors) public bool Success => ErrorMessages.Count == 0; public bool HasErrors => ErrorMessages.Count > 0; public List ErrorMessages { get; } = errors.ToList(); - public CommandResult() : this(Array.Empty()) { } } @@ -29,22 +30,30 @@ public class Success : CommandResult { } -public abstract class CommandResult -{ +public abstract class CommandResult; +public class Success(T value) : CommandResult +{ + public T Value { get; } = value; } -public class Success(T data) : CommandResult +public class Failure(IEnumerable errorMessages) : CommandResult { - public T? Data { get; init; } = data; + public List ErrorMessages { get; } = errorMessages.ToList(); + + public string ErrorMessage => string.Join(" ", ErrorMessages); + + public Failure(string error) : this([error]) { } } -public class Failure(IEnumerable errorMessage) : CommandResult +public class Partial : CommandResult { - public IEnumerable ErrorMessages { get; init; } = errorMessage; + public T[] Successes { get; set; } = []; + public Error[] Failures { get; set; } = []; - public Failure(string errorMessage) : this(new[] { errorMessage }) + public Partial(IEnumerable successfulItems, IEnumerable> failedItems) { + Successes = successfulItems.ToArray(); + Failures = failedItems.ToArray(); } } - diff --git a/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs new file mode 100644 index 0000000000..abb49c25c6 --- /dev/null +++ b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Shared.Validation; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Shared; + +public class IValidatorTests +{ + public class TestClass + { + public string Name { get; set; } = string.Empty; + } + + public record InvalidRequestError(T ErroredValue) : Error(Code, ErroredValue) + { + public const string Code = "InvalidRequest"; + } + + public class TestClassValidator : IValidator + { + public Task> ValidateAsync(TestClass value) + { + if (string.IsNullOrWhiteSpace(value.Name)) + { + return Task.FromResult>(new Invalid + { + Errors = [new InvalidRequestError(value)] + }); + } + + return Task.FromResult>(new Valid { Value = value }); + } + } + + [Fact] + public async Task ValidateAsync_WhenSomethingIsInvalid_ReturnsInvalidWithError() + { + var example = new TestClass(); + + var result = await new TestClassValidator().ValidateAsync(example); + + Assert.IsType>(result); + var invalidResult = result as Invalid; + Assert.Equal(InvalidRequestError.Code, invalidResult.Errors.First().Message); + } + + [Fact] + public async Task ValidateAsync_WhenIsValid_ReturnsValid() + { + var example = new TestClass { Name = "Valid" }; + + var result = await new TestClassValidator().ValidateAsync(example); + + Assert.IsType>(result); + var validResult = result as Valid; + Assert.Equal(example.Name, validResult.Value.Name); + } +} diff --git a/test/Core.Test/Models/Commands/CommandResultTests.cs b/test/Core.Test/Models/Commands/CommandResultTests.cs new file mode 100644 index 0000000000..c500fef4f5 --- /dev/null +++ b/test/Core.Test/Models/Commands/CommandResultTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.Models.Commands; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Models.Commands; + +public class CommandResultTests +{ + public class TestItem + { + public Guid Id { get; set; } + public string Value { get; set; } + } + + public CommandResult BulkAction(IEnumerable items) + { + var itemList = items.ToList(); + var successfulItems = items.Where(x => x.Value == "SuccessfulRequest").ToArray(); + + var failedItems = itemList.Except(successfulItems).ToArray(); + + var notFound = failedItems.First(x => x.Value == "Failed due to not found"); + var invalidPermissions = failedItems.First(x => x.Value == "Failed due to invalid permissions"); + + var notFoundError = new RecordNotFoundError(notFound); + var insufficientPermissionsError = new InsufficientPermissionsError(invalidPermissions); + + return new Partial(successfulItems.ToArray(), [notFoundError, insufficientPermissionsError]); + } + + [Theory] + [BitAutoData] + public void Partial_CommandResult_BulkRequestWithSuccessAndFailures(Guid successId1, Guid failureId1, Guid failureId2) + { + var listOfRecords = new List + { + new TestItem() { Id = successId1, Value = "SuccessfulRequest" }, + new TestItem() { Id = failureId1, Value = "Failed due to not found" }, + new TestItem() { Id = failureId2, Value = "Failed due to invalid permissions" } + }; + + var result = BulkAction(listOfRecords); + + Assert.IsType>(result); + + var failures = (result as Partial).Failures.ToArray(); + var success = (result as Partial).Successes.First(); + + Assert.Equal(listOfRecords.First(), success); + Assert.Equal(2, failures.Length); + } +} From 27606e2d334f03d59571c5eb6b4c123e40f9071a Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:22:22 -0400 Subject: [PATCH 03/43] [PM-3553] Feature flag: Mobile SimpleLogin self host alias generation (#5392) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9cbf6b788a..e09422871d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -176,6 +176,7 @@ public static class FeatureFlagKeys public const string AndroidImportLoginsFlow = "import-logins-flow"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; + public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; public static List GetAllKeys() { From abfdf6f5cb0f1f1504dbaaaa0e04ce9cb60faf19 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 17 Mar 2025 12:37:34 -0400 Subject: [PATCH 04/43] Revert "[PM-18944] Update error response from invalid OTP" (#5504) * Revert "[PM-18944] Update error response from invalid OTP (#5485)" This reverts commit 1b90bfe2a114e73b583100e955e57ac48b9733b9. --- .../RequestValidators/DeviceValidator.cs | 13 +++++++++---- .../IdentityServer/DeviceValidatorTests.cs | 8 ++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs index 4744f4aca3..36a08326ab 100644 --- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs @@ -250,21 +250,26 @@ public class DeviceValidator( var customResponse = new Dictionary(); switch (errorType) { + /* + * The ErrorMessage is brittle and is used to control the flow in the clients. Do not change them without updating the client as well. + * There is a backwards compatibility issue as well: if you make a change on the clients then ensure that they are backwards + * compatible. + */ case DeviceValidationResultType.InvalidUser: result.ErrorDescription = "Invalid user"; - customResponse.Add("ErrorModel", new ErrorResponseModel("Invalid user.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("invalid user")); break; case DeviceValidationResultType.InvalidNewDeviceOtp: result.ErrorDescription = "Invalid New Device OTP"; - customResponse.Add("ErrorModel", new ErrorResponseModel("Invalid new device OTP. Try again.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("invalid new device otp")); break; case DeviceValidationResultType.NewDeviceVerificationRequired: result.ErrorDescription = "New device verification required"; - customResponse.Add("ErrorModel", new ErrorResponseModel("New device verification required.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("new device verification required")); break; case DeviceValidationResultType.NoDeviceInformationProvided: result.ErrorDescription = "No device information provided"; - customResponse.Add("ErrorModel", new ErrorResponseModel("No device information provided.")); + customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided")); break; } return (result, customResponse); diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs index bcb357d640..b71dd6c230 100644 --- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -172,7 +172,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); - var expectedErrorModel = new ErrorResponseModel("No device information provided."); + var expectedErrorModel = new ErrorResponseModel("no device information provided"); var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorModel.Message, actualResponse.Message); } @@ -418,7 +418,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); // PM-13340: The error message should be "invalid user" instead of "no device information provided" - var expectedErrorMessage = "No device information provided."; + var expectedErrorMessage = "no device information provided"; var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorMessage, actualResponse.Message); } @@ -552,7 +552,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); - var expectedErrorMessage = "Invalid new device OTP. Try again."; + var expectedErrorMessage = "invalid new device otp"; var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorMessage, actualResponse.Message); } @@ -604,7 +604,7 @@ public class DeviceValidatorTests Assert.False(result); Assert.NotNull(context.CustomResponse["ErrorModel"]); - var expectedErrorMessage = "New device verification required."; + var expectedErrorMessage = "new device verification required"; var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"]; Assert.Equal(expectedErrorMessage, actualResponse.Message); } From d3f8a99fa6d40ea3346560eb7a5259617ed536e4 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:20:51 -0400 Subject: [PATCH 05/43] [PM-18175] Remove flag check for 2FA recovery code login (#5513) * Remove server-side flagging * Linting * Linting. --- .../RequestValidators/TwoFactorAuthenticationValidator.cs | 8 ++------ .../TwoFactorAuthenticationValidatorTests.cs | 5 +---- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs index 856846cdd6..e733d4f410 100644 --- a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; @@ -155,12 +154,9 @@ public class TwoFactorAuthenticationValidator( return false; } - if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin)) + if (type is TwoFactorProviderType.RecoveryCode) { - if (type is TwoFactorProviderType.RecoveryCode) - { - return await _userService.RecoverTwoFactorAsync(user, token); - } + return await _userService.RecoverTwoFactorAsync(user, token); } // These cases we want to always return false, U2f is deprecated and OrganizationDuo diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index e59a66a9e7..fb4d7c321a 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; @@ -464,7 +463,6 @@ public class TwoFactorAuthenticationValidatorTests user.TwoFactorRecoveryCode = token; _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(true); - _featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true); // Act var result = await _sut.VerifyTwoFactorAsync( @@ -486,7 +484,6 @@ public class TwoFactorAuthenticationValidatorTests user.TwoFactorRecoveryCode = token; _userService.RecoverTwoFactorAsync(Arg.Is(user), Arg.Is(token)).Returns(false); - _featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeLogin).Returns(true); // Act var result = await _sut.VerifyTwoFactorAsync( From 43d0f1052b4b0aeca3f57d729a42b6a4f84b9aa9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:04:54 +0100 Subject: [PATCH 06/43] [deps] Tools: Update MailKit to 4.11.0 (#5515) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 8a8de3d77d..2a3edcdc00 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -36,7 +36,7 @@ - + From 87cdb923a5a3bf1b0a23aff4641728505dd9563b Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 18 Mar 2025 11:44:36 -0400 Subject: [PATCH 07/43] [PM-17901] Replaced hard-coded Bitwarden Vault URLs (#5458) * Replaced hard-coded Bitwarden Vault URLs * Jared's feedback --- ...nizationUserRevokedForSingleOrgPolicy.html.hbs | 2 +- ...nizationUserRevokedForSingleOrgPolicy.text.hbs | 2 +- .../OrganizationSeatsAutoscaled.html.hbs | 2 +- .../OrganizationSeatsMaxReached.html.hbs | 2 +- .../OrganizationSmSeatsMaxReached.html.hbs | 2 +- ...ganizationSmServiceAccountsMaxReached.html.hbs | 2 +- .../Mail/OrganizationSeatsAutoscaledViewModel.cs | 2 +- .../Mail/OrganizationSeatsMaxReachedViewModel.cs | 2 +- ...anizationServiceAccountsMaxReachedViewModel.cs | 2 +- .../Implementations/HandlebarsMailService.cs | 15 +++++++++++---- 10 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs index d04abe86c9..5b2b1a70c5 100644 --- a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs @@ -7,7 +7,7 @@ - To leave an organization, first log into the web app, select the three dot menu next to the organization name, and select Leave. + To leave an organization, first log into the web app, select the three dot menu next to the organization name, and select Leave. diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs index f933e8cf62..6a4b48006b 100644 --- a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs @@ -1,5 +1,5 @@ {{#>BasicTextLayout}} Your user account has been revoked from the {{OrganizationName}} organization because your account is part of multiple organizations. Before you can rejoin {{OrganizationName}}, you must first leave all other organizations. -To leave an organization, first log in the web app (https://vault.bitwarden.com/#/login), select the three dot menu next to the organization name, and select Leave. +To leave an organization, first log in the web app ({{{WebVaultUrl}}}/login), select the three dot menu next to the organization name, and select Leave. {{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs index 8277e3894a..6bdb982194 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsAutoscaled.html.hbs @@ -26,7 +26,7 @@ - + Manage subscription
diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs index 6ac2ee74a5..49dbe41c72 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSeatsMaxReached.html.hbs @@ -24,7 +24,7 @@ - + Manage subscription
diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs index a6db21effc..2ef6707f1f 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmSeatsMaxReached.html.hbs @@ -24,7 +24,7 @@ - + Manage subscription
diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs index 507fdc33a9..1f4300c23e 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -24,7 +24,7 @@ - + Manage subscription
diff --git a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs index 87f87b1c69..425b853d3e 100644 --- a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs +++ b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs @@ -2,7 +2,7 @@ public class OrganizationSeatsAutoscaledViewModel : BaseMailModel { - public Guid OrganizationId { get; set; } public int InitialSeatCount { get; set; } public int CurrentSeatCount { get; set; } + public string VaultSubscriptionUrl { get; set; } } diff --git a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs index cdfb57b2dc..ad9c48ab31 100644 --- a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs @@ -2,6 +2,6 @@ public class OrganizationSeatsMaxReachedViewModel : BaseMailModel { - public Guid OrganizationId { get; set; } public int MaxSeatCount { get; set; } + public string VaultSubscriptionUrl { get; set; } } diff --git a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs index 1b9c925720..c814a3e564 100644 --- a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs @@ -2,6 +2,6 @@ public class OrganizationServiceAccountsMaxReachedViewModel { - public Guid OrganizationId { get; set; } public int MaxServiceAccountsCount { get; set; } + public string VaultSubscriptionUrl { get; set; } } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index c598a9d432..d96f69b0a6 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -214,9 +214,9 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Count Has Increased", ownerEmails); var model = new OrganizationSeatsAutoscaledViewModel { - OrganizationId = organization.Id, InitialSeatCount = initialSeatCount, CurrentSeatCount = organization.Seats.Value, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSeatsAutoscaled", model); @@ -229,8 +229,8 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Seat Limit Reached", ownerEmails); var model = new OrganizationSeatsMaxReachedViewModel { - OrganizationId = organization.Id, MaxSeatCount = maxSeatCount, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSeatsMaxReached", model); @@ -1103,8 +1103,8 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Seat Limit Reached", ownerEmails); var model = new OrganizationSeatsMaxReachedViewModel { - OrganizationId = organization.Id, MaxSeatCount = maxSeatCount, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model); @@ -1118,8 +1118,8 @@ public class HandlebarsMailService : IMailService var message = CreateDefaultMessage($"{organization.DisplayName()} Secrets Manager Machine Accounts Limit Reached", ownerEmails); var model = new OrganizationServiceAccountsMaxReachedViewModel { - OrganizationId = organization.Id, MaxServiceAccountsCount = maxSeatCount, + VaultSubscriptionUrl = GetCloudVaultSubscriptionUrl(organization.Id) }; await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model); @@ -1223,4 +1223,11 @@ public class HandlebarsMailService : IMailService { return string.IsNullOrEmpty(userName) ? email : CoreHelpers.SanitizeForEmail(userName, false); } + + private string GetCloudVaultSubscriptionUrl(Guid organizationId) + => _globalSettings.BaseServiceUri.CloudRegion?.ToLower() switch + { + "eu" => $"https://vault.bitwarden.eu/#/organizations/{organizationId}/billing/subscription", + _ => $"https://vault.bitwarden.com/#/organizations/{organizationId}/billing/subscription" + }; } From 508bf2c9f846de1d184acd65d6dab1ccea19b9fc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:26:29 -0400 Subject: [PATCH 08/43] [deps] Vault: Update AngleSharp to 1.2.0 (#5220) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Icons/Icons.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj index 1674e2f877..455c8b3155 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -7,7 +7,7 @@ - + From 7f0dd6d1c320bb9f8ec336cad11521ae98150fa0 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Tue, 18 Mar 2025 13:02:39 -0700 Subject: [PATCH 09/43] Update FROM directive in Dockerfile (#5522) --- util/Attachments/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/Attachments/Dockerfile b/util/Attachments/Dockerfile index 2d99aa5911..37b23a1b95 100644 --- a/util/Attachments/Dockerfile +++ b/util/Attachments/Dockerfile @@ -1,4 +1,4 @@ -FROM bitwarden/server:latest +FROM ghcr.io/bitwarden/server LABEL com.bitwarden.product="bitwarden" From bb3ec6aca13b691120b052747f6b4198e639d97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:01:06 +0000 Subject: [PATCH 10/43] [PM-16888] Refactor OrganizationUser status update procedure to use a GuidIdArray parameter and remove JSON parsing logic (#5237) * Refactor OrganizationUser status update procedure to use a GuidIdArray parameter and remove JSON parsing logic * Fix OrganizationUser_SetStatusForUsersById procedure and bump script date * Restore OrganizationUser_SetStatusForUsersById for possible server version rollback. Add new version with the name OrganizationUser_SetStatusForUsersByGuidIdArray * Add migration script to add stored procedure OrganizationUser_SetStatusForUsersByGuidIdArray to update user status by GUID array --- .../Repositories/OrganizationUserRepository.cs | 4 ++-- ...izationUser_SetStatusForUsersByGuidIdArray.sql | 14 ++++++++++++++ ...0_AddOrgUserSetStatusForUsersByGuidIdArray.sql | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql create mode 100644 util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 9b77fb216e..07b55aa44a 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -563,8 +563,8 @@ public class OrganizationUserRepository : Repository, IO await using var connection = new SqlConnection(ConnectionString); await connection.ExecuteAsync( - "[dbo].[OrganizationUser_SetStatusForUsersById]", - new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked }, + "[dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray]", + new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP(), Status = OrganizationUserStatusType.Revoked }, commandType: CommandType.StoredProcedure); } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql new file mode 100644 index 0000000000..7843748d72 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersByGuidIdArray.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray] + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @Status SMALLINT +AS +BEGIN + SET NOCOUNT ON + + UPDATE OU + SET OU.[Status] = @Status + FROM [dbo].[OrganizationUser] OU + INNER JOIN @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds +END diff --git a/util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql b/util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql new file mode 100644 index 0000000000..e7c0477710 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-13-00_AddOrgUserSetStatusForUsersByGuidIdArray.sql @@ -0,0 +1,15 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersByGuidIdArray] + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY, + @Status SMALLINT +AS +BEGIN + SET NOCOUNT ON + + UPDATE OU + SET OU.[Status] = @Status + FROM [dbo].[OrganizationUser] OU + INNER JOIN @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds +END +GO From fc827ed2097ca601c8933760993ce602373c3789 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 19 Mar 2025 13:49:02 -0400 Subject: [PATCH 11/43] feat(set password) [PM-17647] Add set/change password feature flags * Added flag values * Added flag values * Removed extra space * Linting --- src/Core/Constants.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e09422871d..fb3757daab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -177,6 +177,8 @@ public static class FeatureFlagKeys public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; + public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; + public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public static List GetAllKeys() { From 21717ec71eb59057f3ac71dcce4c467f17d92bbc Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:13:38 -0700 Subject: [PATCH 12/43] [PM-17733] - [Privilege Escalation] - Unauthorised access allows limited access user to change password of Items (#5452) * prevent view-only users from updating passwords * revert change to licensing service * add tests * check if organizationId is there * move logic to private method * move logic to private method * move logic into method * revert change to licensing service * throw exception when cipher key is created by hidden password users * fix tests * don't allow totp or passkeys changes from hidden password users * add tests * revert change to licensing service --- .../Services/Implementations/CipherService.cs | 36 ++- .../Vault/Services/CipherServiceTests.cs | 232 +++++++++++++++++- 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 90c03df90b..a315528e59 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -13,7 +13,9 @@ using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; namespace Bit.Core.Vault.Services; @@ -38,6 +40,7 @@ public class CipherService : ICipherService private const long _fileSizeLeeway = 1024L * 1024L; // 1MB private readonly IReferenceEventService _referenceEventService; private readonly ICurrentContext _currentContext; + private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; public CipherService( ICipherRepository cipherRepository, @@ -54,7 +57,8 @@ public class CipherService : ICipherService IPolicyService policyService, GlobalSettings globalSettings, IReferenceEventService referenceEventService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -71,6 +75,7 @@ public class CipherService : ICipherService _globalSettings = globalSettings; _referenceEventService = referenceEventService; _currentContext = currentContext; + _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, @@ -161,6 +166,7 @@ public class CipherService : ICipherService { ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; + await ValidateViewPasswordUserAsync(cipher); await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_Updated); @@ -966,4 +972,32 @@ public class CipherService : ICipherService ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); } + + private async Task ValidateViewPasswordUserAsync(Cipher cipher) + { + if (cipher.Type != CipherType.Login || cipher.Data == null || !cipher.OrganizationId.HasValue) + { + return; + } + var existingCipher = await _cipherRepository.GetByIdAsync(cipher.Id); + if (existingCipher == null) return; + + var cipherPermissions = await _getCipherPermissionsForUserQuery.GetByOrganization(cipher.OrganizationId.Value); + // Check if user is a "hidden password" user + if (!cipherPermissions.TryGetValue(cipher.Id, out var permission) || !(permission.ViewPassword && permission.Edit)) + { + // "hidden password" users may not add cipher key encryption + if (existingCipher.Key == null && cipher.Key != null) + { + throw new BadRequestException("You do not have permission to add cipher key encryption."); + } + // "hidden password" users may not change passwords, TOTP codes, or passkeys, so we need to set them back to the original values + var existingCipherData = JsonSerializer.Deserialize(existingCipher.Data); + var newCipherData = JsonSerializer.Deserialize(cipher.Data); + newCipherData.Fido2Credentials = existingCipherData.Fido2Credentials; + newCipherData.Totp = existingCipherData.Totp; + newCipherData.Password = existingCipherData.Password; + cipher.Data = JsonSerializer.Serialize(newCipherData); + } + } } diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 1803c980c2..3ef29146c2 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -9,7 +10,9 @@ using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CipherFixtures; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; @@ -797,6 +800,233 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + private class SaveDetailsAsyncDependencies + { + public CipherDetails CipherDetails { get; set; } + public SutProvider SutProvider { get; set; } + } + + private static SaveDetailsAsyncDependencies GetSaveDetailsAsyncDependencies( + SutProvider sutProvider, + string newPassword, + bool viewPassword, + bool editPermission, + string? key = null, + string? totp = null, + CipherLoginFido2CredentialData[]? passkeys = null + ) + { + var cipherDetails = new CipherDetails + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Type = CipherType.Login, + UserId = Guid.NewGuid(), + RevisionDate = DateTime.UtcNow, + Key = key, + }; + + var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys }; + cipherDetails.Data = JsonSerializer.Serialize(newLoginData); + + var existingCipher = new Cipher + { + Id = cipherDetails.Id, + Data = JsonSerializer.Serialize( + new CipherLoginData + { + Username = "user", + Password = "OriginalPassword", + Totp = "OriginalTotp", + Fido2Credentials = [] + } + ), + }; + + sutProvider.GetDependency() + .GetByIdAsync(cipherDetails.Id) + .Returns(existingCipher); + + sutProvider.GetDependency() + .ReplaceAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + var permissions = new Dictionary + { + { cipherDetails.Id, new OrganizationCipherPermission { ViewPassword = viewPassword, Edit = editPermission } } + }; + + sutProvider.GetDependency() + .GetByOrganization(cipherDetails.OrganizationId.Value) + .Returns(permissions); + + return new SaveDetailsAsyncDependencies + { + CipherDetails = cipherDetails, + SutProvider = sutProvider, + }; + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_PasswordNotChangedWithoutViewPasswordPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: true); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("OriginalPassword", updatedLoginData.Password); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_PasswordNotChangedWithoutEditPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("OriginalPassword", updatedLoginData.Password); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_PasswordChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("NewPassword", updatedLoginData.Password); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_CipherKeyChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, "NewKey"); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + Assert.Equal("NewKey", deps.CipherDetails.Key); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_CipherKeyChangedWithoutPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, "NewKey"); + + var exception = await Assert.ThrowsAsync(() => deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true)); + + Assert.Contains("do not have permission", exception.Message); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_TotpChangedWithoutPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, totp: "NewTotp"); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("OriginalTotp", updatedLoginData.Totp); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_TotpChangedWithPermission(string _, SutProvider sutProvider) + { + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, totp: "NewTotp"); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal("NewTotp", updatedLoginData.Totp); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_Fido2CredentialsChangedWithoutPermission(string _, SutProvider sutProvider) + { + var passkeys = new[] + { + new CipherLoginFido2CredentialData + { + CredentialId = "CredentialId", + UserHandle = "UserHandle", + } + }; + + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, passkeys: passkeys); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Empty(updatedLoginData.Fido2Credentials); + } + + [Theory, BitAutoData] + public async Task SaveDetailsAsync_Fido2CredentialsChangedWithPermission(string _, SutProvider sutProvider) + { + var passkeys = new[] + { + new CipherLoginFido2CredentialData + { + CredentialId = "CredentialId", + UserHandle = "UserHandle", + } + }; + + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, passkeys: passkeys); + + await deps.SutProvider.Sut.SaveDetailsAsync( + deps.CipherDetails, + deps.CipherDetails.UserId.Value, + deps.CipherDetails.RevisionDate, + null, + true); + + var updatedLoginData = JsonSerializer.Deserialize(deps.CipherDetails.Data); + Assert.Equal(passkeys.Length, updatedLoginData.Fido2Credentials.Length); + } + [Theory] [BitAutoData] public async Task DeleteAsync_WithPersonalCipherOwner_DeletesCipher( From 481df89cf0991c02dc9ddd194de83c92cf72db14 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 19 Mar 2025 14:24:12 -0400 Subject: [PATCH 13/43] [PM-19342] Onboarding Nudges Feature Flag (#5530) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fb3757daab..970bcb4082 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -118,6 +118,7 @@ public static class FeatureFlagKeys public const string ExportAttachments = "export-attachments"; /* Vault Team */ + public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; From 3422f4cd5033c751a0479ed91e5b15820dfdd997 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 19 Mar 2025 13:55:30 -0500 Subject: [PATCH 14/43] [PM-18971] Special Characters in Org Names (#5514) * sanitize organization name for email to avoid encoding * fix spelling mistake in variable name --- src/Core/Services/IMailService.cs | 2 +- .../Services/Implementations/HandlebarsMailService.cs | 9 +++++---- src/Core/Services/NoopImplementations/NoopMailService.cs | 2 +- .../Vault/Commands/CreateManyTaskNotificationsCommand.cs | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index b0b884eb3e..04b302bad9 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -99,5 +99,5 @@ public interface IMailService string organizationName); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); - Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons); + Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index d96f69b0a6..588365f8c9 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -1201,21 +1201,22 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons) + public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications) { MailQueueMessage CreateMessage(UserSecurityTasksCount notification) { - var message = CreateDefaultMessage($"{orgName} has identified {notification.TaskCount} at-risk password{(notification.TaskCount.Equals(1) ? "" : "s")}", notification.Email); + var sanitizedOrgName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false); + var message = CreateDefaultMessage($"{sanitizedOrgName} has identified {notification.TaskCount} at-risk password{(notification.TaskCount.Equals(1) ? "" : "s")}", notification.Email); var model = new SecurityTaskNotificationViewModel { - OrgName = orgName, + OrgName = CoreHelpers.SanitizeForEmail(sanitizedOrgName, false), TaskCount = notification.TaskCount, WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, }; message.Category = "SecurityTasksNotification"; return new MailQueueMessage(message, "SecurityTasksNotification", model); } - var messageModels = securityTaskNotificaitons.Select(CreateMessage); + var messageModels = securityTaskNotifications.Select(CreateMessage); await EnqueueMailAsync(messageModels.ToList()); } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 5fba545903..776dd07f19 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -324,7 +324,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendBulkSecurityTaskNotificationsAsync(string orgName, IEnumerable securityTaskNotificaitons) + public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications) { return Task.FromResult(0); } diff --git a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs index 58b5f65e0f..f939816301 100644 --- a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs +++ b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs @@ -46,7 +46,7 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo var organization = await _organizationRepository.GetByIdAsync(orgId); - await _mailService.SendBulkSecurityTaskNotificationsAsync(organization.Name, userTaskCount); + await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount); // Break securityTaskCiphers into separate lists by user Id var securityTaskCiphersByUser = securityTaskCiphers.GroupBy(x => x.UserId) From db3151160a06daf0dac896d061e4dca2b982682c Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 19 Mar 2025 15:27:51 -0400 Subject: [PATCH 15/43] fix(device-approval-persistence): [PM-9112] Device Approval Persistence - Added feature flag. (#5495) --- src/Core/Constants.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 970bcb4082..0ae9f1d8d7 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -126,6 +126,9 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; + /* Auth Team */ + public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; + public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string DuoRedirect = "duo-redirect"; From f6cc140fded360256429b24a378cbcd969e7d50a Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 20 Mar 2025 09:12:39 -0500 Subject: [PATCH 16/43] Switched from .Any to Count. Remove unreachable code. (#5519) --- src/Api/Vault/Controllers/CiphersController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 62f07005ee..daaf8a03fb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -127,9 +127,9 @@ public class CiphersController : Controller public async Task> Get() { var user = await _userService.GetUserByPrincipalAsync(User); - var hasOrgs = _currentContext.Organizations?.Any() ?? false; + var hasOrgs = _currentContext.Organizations.Count != 0; // TODO: Use hasOrgs proper for cipher listing here? - var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: true || hasOrgs); + var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: true); Dictionary> collectionCiphersGroupDict = null; if (hasOrgs) { From bb674b8990499f0c6680fb5efb009c9316a99218 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Thu, 20 Mar 2025 17:14:35 +0000 Subject: [PATCH 17/43] Bumped version to 2025.3.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index a994b2196e..082f85906d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.0 + 2025.3.1 Bit.$(MSBuildProjectName) enable From 2d02ad3f61b939d08bc901e510007d8ed9ce2fe1 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Thu, 20 Mar 2025 17:30:55 +0000 Subject: [PATCH 18/43] Bumped version to 2025.3.2 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 082f85906d..cbe9786d65 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.1 + 2025.3.2 Bit.$(MSBuildProjectName) enable From 948d8f707d7d98506b6f87f4157721e8b2838c22 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:41:58 -0500 Subject: [PATCH 19/43] [PM-18858] Security Task email bugs (#5536) * make "Review at-risk passwords" bold * add owner and admin email address to the bottom of the security notification email * fix plurality of text email --- .../SecurityTasksNotification.html.hbs | 11 ++++- .../SecurityTasksNotification.text.hbs | 9 ++++ .../Mail/SecurityTaskNotificationViewModel.cs | 2 + src/Core/Services/IMailService.cs | 2 +- .../Implementations/HandlebarsMailService.cs | 42 ++++++++++++++++++- .../NoopImplementations/NoopMailService.cs | 2 +- .../CreateManyTaskNotificationsCommand.cs | 10 ++++- 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs index 039806f44b..ca015e3e83 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs @@ -15,14 +15,21 @@ + style="display: table; width:100%; padding-bottom: 24px; text-align: center;" align="center"> +
+ style="display: inline-block; font-weight: bold; color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; border-radius: 999px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;"> Review at-risk passwords
+ + +
+ {{formatAdminOwnerEmails AdminOwnerEmails}} +
{{/SecurityTasksHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs index ba8650ad10..f5493e4503 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs @@ -5,4 +5,13 @@ breach. Launch the Bitwarden extension to review your at-risk passwords. Review at-risk passwords ({{{ReviewPasswordsUrl}}}) + +{{#if (eq (length AdminOwnerEmails) 1)}} +This request was initiated by {{AdminOwnerEmails.[0]}}. +{{else}} +This request was initiated by +{{#each AdminOwnerEmails}} + {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}} +{{/each}}. +{{/if}} {{/SecurityTasksHtmlLayout}} diff --git a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs index 7f93ac2439..8871a53424 100644 --- a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs +++ b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs @@ -8,5 +8,7 @@ public class SecurityTaskNotificationViewModel : BaseMailModel public bool TaskCountPlural => TaskCount != 1; + public IEnumerable AdminOwnerEmails { get; set; } + public string ReviewPasswordsUrl => $"{WebVaultUrl}/browser-extension-prompt"; } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 04b302bad9..e61127c57a 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -99,5 +99,5 @@ public interface IMailService string organizationName); Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable adminEmails, Guid organizationId, string email, string userName); - Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications); + Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 588365f8c9..edb99809f7 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -740,6 +740,45 @@ public class HandlebarsMailService : IMailService var clickTrackingText = (clickTrackingOff ? "clicktracking=off" : string.Empty); writer.WriteSafeString($"{text}"); }); + + // Construct markup for admin and owner email addresses. + // Using conditionals within the handlebar syntax was including extra spaces around + // concatenated strings, which this helper avoids. + Handlebars.RegisterHelper("formatAdminOwnerEmails", (writer, context, parameters) => + { + if (parameters.Length == 0) + { + writer.WriteSafeString(string.Empty); + return; + } + + var emailList = ((IEnumerable)parameters[0]).ToList(); + if (emailList.Count == 0) + { + writer.WriteSafeString(string.Empty); + return; + } + + string constructAnchorElement(string email) + { + return $"{email}"; + } + + var outputMessage = "This request was initiated by "; + + if (emailList.Count == 1) + { + outputMessage += $"{constructAnchorElement(emailList[0])}."; + } + else + { + outputMessage += string.Join(", ", emailList.Take(emailList.Count - 1) + .Select(email => constructAnchorElement(email))); + outputMessage += $", and {constructAnchorElement(emailList.Last())}."; + } + + writer.WriteSafeString($"{outputMessage}"); + }); } public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) @@ -1201,7 +1240,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications) + public async Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails) { MailQueueMessage CreateMessage(UserSecurityTasksCount notification) { @@ -1211,6 +1250,7 @@ public class HandlebarsMailService : IMailService { OrgName = CoreHelpers.SanitizeForEmail(sanitizedOrgName, false), TaskCount = notification.TaskCount, + AdminOwnerEmails = adminOwnerEmails, WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, }; message.Category = "SecurityTasksNotification"; diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 776dd07f19..d829fbbacb 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -324,7 +324,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications) + public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable securityTaskNotifications, IEnumerable adminOwnerEmails) { return Task.FromResult(0); } diff --git a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs index f939816301..a335b059a4 100644 --- a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs +++ b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs @@ -17,19 +17,22 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo private readonly IMailService _mailService; private readonly ICreateNotificationCommand _createNotificationCommand; private readonly IPushNotificationService _pushNotificationService; + private readonly IOrganizationUserRepository _organizationUserRepository; public CreateManyTaskNotificationsCommand( IGetSecurityTasksNotificationDetailsQuery getSecurityTasksNotificationDetailsQuery, IOrganizationRepository organizationRepository, IMailService mailService, ICreateNotificationCommand createNotificationCommand, - IPushNotificationService pushNotificationService) + IPushNotificationService pushNotificationService, + IOrganizationUserRepository organizationUserRepository) { _getSecurityTasksNotificationDetailsQuery = getSecurityTasksNotificationDetailsQuery; _organizationRepository = organizationRepository; _mailService = mailService; _createNotificationCommand = createNotificationCommand; _pushNotificationService = pushNotificationService; + _organizationUserRepository = organizationUserRepository; } public async Task CreateAsync(Guid orgId, IEnumerable securityTasks) @@ -45,8 +48,11 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo }).ToList(); var organization = await _organizationRepository.GetByIdAsync(orgId); + var orgAdminEmails = await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Admin); + var orgOwnerEmails = await _organizationUserRepository.GetManyDetailsByRoleAsync(orgId, OrganizationUserType.Owner); + var orgAdminAndOwnerEmails = orgAdminEmails.Concat(orgOwnerEmails).Select(x => x.Email).Distinct().ToList(); - await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount); + await _mailService.SendBulkSecurityTaskNotificationsAsync(organization, userTaskCount, orgAdminAndOwnerEmails); // Break securityTaskCiphers into separate lists by user Id var securityTaskCiphersByUser = securityTaskCiphers.GroupBy(x => x.UserId) From 5d549402c713f179781c9680952ac95186055934 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Fri, 21 Mar 2025 10:15:22 +0000 Subject: [PATCH 20/43] Bumped version to 2025.3.3 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index cbe9786d65..2ede6ad8d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.2 + 2025.3.3 Bit.$(MSBuildProjectName) enable From c7c6528faaa2fb6f20b9e3e9186404405986abe0 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 21 Mar 2025 10:07:55 -0400 Subject: [PATCH 21/43] Ac/pm 18240 implement policy requirement for reset password policy (#5521) * wip * fix test * fix test * refactor * fix factory method and tests * cleanup * refactor * update copy * cleanup --- .../OrganizationUsersController.cs | 11 ++- .../Controllers/OrganizationsController.cs | 15 ++- .../ResetPasswordPolicyRequirement.cs | 46 ++++++++++ .../PolicyServiceCollectionExtensions.cs | 1 + .../Implementations/OrganizationService.cs | 29 ++++-- .../OrganizationUsersControllerTests.cs | 91 +++++++++++++++++++ .../OrganizationsControllerTests.cs | 57 ++++++++++++ ...etPasswordPolicyRequirementFactoryTests.cs | 37 ++++++++ 8 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5a73e57204..cc7f2314fd 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -8,6 +8,8 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; @@ -55,6 +57,7 @@ public class OrganizationUsersController : Controller private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; @@ -79,6 +82,7 @@ public class OrganizationUsersController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand, IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, IPricingClient pricingClient) { @@ -102,6 +106,7 @@ public class OrganizationUsersController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; } @@ -315,11 +320,13 @@ public class OrganizationUsersController : Controller throw new UnauthorizedAccessException(); } - var useMasterPasswordPolicy = await ShouldHandleResetPasswordAsync(orgId); + var useMasterPasswordPolicy = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + ? (await _policyRequirementQuery.GetAsync(user.Id)).AutoEnrollEnabled(orgId) + : await ShouldHandleResetPasswordAsync(orgId); if (useMasterPasswordPolicy && string.IsNullOrWhiteSpace(model.ResetPasswordKey)) { - throw new BadRequestException(string.Empty, "Master Password reset is required, but not provided."); + throw new BadRequestException("Master Password reset is required, but not provided."); } await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 34da3de10c..9fa9cb6672 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -16,6 +16,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -61,6 +63,7 @@ public class OrganizationsController : Controller private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand; + private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; public OrganizationsController( @@ -84,6 +87,7 @@ public class OrganizationsController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand, IOrganizationDeleteCommand organizationDeleteCommand, + IPolicyRequirementQuery policyRequirementQuery, IPricingClient pricingClient) { _organizationRepository = organizationRepository; @@ -106,6 +110,7 @@ public class OrganizationsController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; _organizationDeleteCommand = organizationDeleteCommand; + _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; } @@ -163,8 +168,13 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id)); + } + + var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) { return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false); @@ -172,6 +182,7 @@ public class OrganizationsController : Controller var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); + } [HttpPost("")] diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs new file mode 100644 index 0000000000..4feef1b088 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs @@ -0,0 +1,46 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Account recovery administration policy. +/// +public class ResetPasswordPolicyRequirement : IPolicyRequirement +{ + /// + /// List of Organization Ids that require automatic enrollment in password recovery. + /// + private IEnumerable _autoEnrollOrganizations; + public IEnumerable AutoEnrollOrganizations { init => _autoEnrollOrganizations = value; } + + /// + /// Returns true if provided organizationId requires automatic enrollment in password recovery. + /// + public bool AutoEnrollEnabled(Guid organizationId) + { + return _autoEnrollOrganizations.Contains(organizationId); + } + + +} + +public class ResetPasswordPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.ResetPassword; + + protected override bool ExemptProviders => false; + + protected override IEnumerable ExemptRoles => []; + + public override ResetPasswordPolicyRequirement Create(IEnumerable policyDetails) + { + var result = policyDetails + .Where(p => p.GetDataModel().AutoEnrollEnabled) + .Select(p => p.OrganizationId) + .ToHashSet(); + + return new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = result }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 6c698f9ffc..d386006ad2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -33,5 +33,6 @@ public static class PolicyServiceCollectionExtensions { services.AddScoped, DisableSendPolicyRequirementFactory>(); services.AddScoped, SendOptionsPolicyRequirementFactory>(); + services.AddScoped, ResetPasswordPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1b44eea496..772b407951 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -6,6 +6,8 @@ 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.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; @@ -76,6 +78,7 @@ public class OrganizationService : IOrganizationService private readonly IOrganizationBillingService _organizationBillingService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public OrganizationService( IOrganizationRepository organizationRepository, @@ -111,7 +114,8 @@ public class OrganizationService : IOrganizationService ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IOrganizationBillingService organizationBillingService, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPricingClient pricingClient) + IPricingClient pricingClient, + IPolicyRequirementQuery policyRequirementQuery) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -147,6 +151,7 @@ public class OrganizationService : IOrganizationService _organizationBillingService = organizationBillingService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; + _policyRequirementQuery = policyRequirementQuery; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -1353,13 +1358,25 @@ public class OrganizationService : IOrganizationService } // Block the user from withdrawal if auto enrollment is enabled - if (resetPasswordKey == null && resetPasswordPolicy.Data != null) + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) { - var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); - - if (data?.AutoEnrollEnabled ?? false) + var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(userId); + if (resetPasswordKey == null && resetPasswordPolicyRequirement.AutoEnrollEnabled(organizationId)) { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from Password Reset."); + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); + } + + } + else + { + if (resetPasswordKey == null && resetPasswordPolicy.Data != null) + { + var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); + + if (data?.AutoEnrollEnabled ?? false) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); + } } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index e3071bd227..a19560ecee 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -7,6 +7,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; @@ -424,4 +426,93 @@ public class OrganizationUsersControllerTests .GetManyDetailsByOrganizationAsync(organizationAbility.Id, Arg.Any(), Arg.Any()) .Returns(organizationUsers); } + + [Theory] + [BitAutoData] + public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_WithPolicyRequirementsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId, + OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + { + // Arrange + var applicationCacheService = sutProvider.GetDependency(); + applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true }); + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var policy = new Policy + { + Enabled = true, + Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), + }; + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + + var policyRequirementQuery = sutProvider.GetDependency(); + + var policyRepository = sutProvider.GetDependency(); + + var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] }; + + policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + + // Act + await sutProvider.Sut.Accept(orgId, orgUserId, model); + + // Assert + await sutProvider.GetDependency().Received(1) + .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService); + await sutProvider.GetDependency().Received(1) + .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); + + await userService.Received(1).GetUserByPrincipalAsync(default); + await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); + await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyRequirementQuery.Received(1).GetAsync(user.Id); + Assert.True(policyRequirement.AutoEnrollEnabled(orgId)); + } + + [Theory] + [BitAutoData] + public async Task Accept_WithInvalidModelResetPasswordKey_WithPolicyRequirementsEnabled_ThrowsBadRequestException(Guid orgId, Guid orgUserId, + OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + { + // Arrange + model.ResetPasswordKey = " "; + var applicationCacheService = sutProvider.GetDependency(); + applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true }); + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var policy = new Policy + { + Enabled = true, + Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), + }; + var userService = sutProvider.GetDependency(); + userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + + var policyRepository = sutProvider.GetDependency(); + + var policyRequirementQuery = sutProvider.GetDependency(); + + var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] }; + + policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + + // Act + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.Accept(orgId, orgUserId, model)); + + // Assert + await sutProvider.GetDependency().Received(0) + .AcceptOrgUserByEmailTokenAsync(orgUserId, user, model.Token, userService); + await sutProvider.GetDependency().Received(0) + .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); + + await userService.Received(1).GetUserByPrincipalAsync(default); + await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); + await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyRequirementQuery.Received(1).GetAsync(user.Id); + + Assert.Equal("Master Password reset is required, but not provided.", exception.Message); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index b0906ddc43..8e6d2ce27b 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -4,12 +4,15 @@ using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -55,6 +58,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly IOrganizationDeleteCommand _organizationDeleteCommand; + private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; private readonly OrganizationsController _sut; @@ -80,6 +84,7 @@ public class OrganizationsControllerTests : IDisposable _removeOrganizationUserCommand = Substitute.For(); _cloudOrganizationSignUpCommand = Substitute.For(); _organizationDeleteCommand = Substitute.For(); + _policyRequirementQuery = Substitute.For(); _pricingClient = Substitute.For(); _sut = new OrganizationsController( @@ -103,6 +108,7 @@ public class OrganizationsControllerTests : IDisposable _removeOrganizationUserCommand, _cloudOrganizationSignUpCommand, _organizationDeleteCommand, + _policyRequirementQuery, _pricingClient); } @@ -236,4 +242,55 @@ public class OrganizationsControllerTests : IDisposable await _organizationDeleteCommand.Received(1).DeleteAsync(organization); } + + [Theory, AutoData] + public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( + User user, + Organization organization, + OrganizationUser organizationUser + ) + { + var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] }; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + _policyRequirementQuery.GetAsync(user.Id).Returns(policyRequirement); + + var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + + await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); + await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await _policyRequirementQuery.Received(1).GetAsync(user.Id); + + Assert.True(result.ResetPasswordEnabled); + Assert.Equal(result.Id, organization.Id); + } + + [Theory, AutoData] + public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue( + User user, + Organization organization, + OrganizationUser organizationUser +) + { + + var policy = new Policy() { Type = PolicyType.ResetPassword, Enabled = true, Data = "{\"AutoEnrollEnabled\": true}", OrganizationId = organization.Id }; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); + _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + _organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); + _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); + + var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString()); + + await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any()); + await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString()); + await _policyRequirementQuery.Received(0).GetAsync(user.Id); + await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + + Assert.True(result.ResetPasswordEnabled); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs new file mode 100644 index 0000000000..181f4f170e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirementFactoryTests.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +[SutProviderCustomize] +public class ResetPasswordPolicyRequirementFactoryTests +{ + [Theory, BitAutoData] + public void AutoEnroll_WithNoPolicies_IsEmpty(SutProvider sutProvider, Guid orgId) + { + var actual = sutProvider.Sut.Create([]); + + Assert.False(actual.AutoEnrollEnabled(orgId)); + } + + [Theory, BitAutoData] + public void AutoEnrollAdministration_WithAnyResetPasswordPolices_ReturnsEnabledOrganizationIds( + [PolicyDetails(PolicyType.ResetPassword)] PolicyDetails[] policies, + SutProvider sutProvider) + { + policies[0].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); + policies[1].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = false }); + policies[2].SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); + + var actual = sutProvider.Sut.Create(policies); + + Assert.True(actual.AutoEnrollEnabled(policies[0].OrganizationId)); + Assert.False(actual.AutoEnrollEnabled(policies[1].OrganizationId)); + Assert.True(actual.AutoEnrollEnabled(policies[2].OrganizationId)); + } +} From 7eb8ad8fa3a0d9975da0722874f777738d818cfe Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 24 Mar 2025 10:49:33 +0000 Subject: [PATCH 22/43] Bumped version to 2025.3.4 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 2ede6ad8d1..0ef5513003 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.3 + 2025.3.4 Bit.$(MSBuildProjectName) enable From 1db37a14abdb20a06ebe853de1aaa569782c1d13 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 24 Mar 2025 10:56:04 +0000 Subject: [PATCH 23/43] Bumped version to 2025.3.5 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0ef5513003..0d17b69b55 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.4 + 2025.3.5 Bit.$(MSBuildProjectName) enable From d345937ecca07aefd07f8cea421ae7ca2b6fcf98 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 24 Mar 2025 11:22:29 +0000 Subject: [PATCH 24/43] Bumped version to 2025.3.6 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0d17b69b55..b9b8cf557a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.5 + 2025.3.6 Bit.$(MSBuildProjectName) enable From 24b63f2dcd12e1268fa625c8e1bef3e16c2aa17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:05:46 +0000 Subject: [PATCH 25/43] [PM-12493] Extract ConfirmUser methods from OrganizationService into commands (#5505) * Add ConfirmOrganizationUserCommand and IConfirmOrganizationUserCommand interface for managing organization user confirmations * Add unit tests for ConfirmOrganizationUserCommand to validate user confirmation scenarios * Register ConfirmOrganizationUserCommand for dependency injection * Refactor OrganizationUsersController to utilize IConfirmOrganizationUserCommand for user confirmation processes * Remove ConfirmUserAsync and ConfirmUsersAsync methods from IOrganizationService and OrganizationService * Rename test methods in ConfirmOrganizationUserCommandTests for clarity and consistency * Update test method name in ConfirmOrganizationUserCommandTests for improved clarity --- .../OrganizationUsersController.cs | 11 +- .../ConfirmOrganizationUserCommand.cs | 186 ++++++++++ .../IConfirmOrganizationUserCommand.cs | 30 ++ .../Services/IOrganizationService.cs | 3 - .../Implementations/OrganizationService.cs | 126 ------- ...OrganizationServiceCollectionExtensions.cs | 1 + .../ConfirmOrganizationUserCommandTests.cs | 324 ++++++++++++++++++ .../Services/OrganizationServiceTests.cs | 301 ---------------- 8 files changed, 548 insertions(+), 434 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index cc7f2314fd..cfe93e87ce 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -60,6 +60,7 @@ public class OrganizationUsersController : Controller private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; + private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -84,7 +85,8 @@ public class OrganizationUsersController : Controller IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, - IPricingClient pricingClient) + IPricingClient pricingClient, + IConfirmOrganizationUserCommand confirmOrganizationUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -109,6 +111,7 @@ public class OrganizationUsersController : Controller _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; + _confirmOrganizationUserCommand = confirmOrganizationUserCommand; } [HttpGet("{id}")] @@ -308,7 +311,7 @@ public class OrganizationUsersController : Controller await _organizationService.InitPendingOrganization(user.Id, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName); await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService); - await _organizationService.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); + await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id); } [HttpPost("{organizationUserId}/accept")] @@ -364,7 +367,7 @@ public class OrganizationUsersController : Controller } var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value); + var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value); } [HttpPost("confirm")] @@ -378,7 +381,7 @@ public class OrganizationUsersController : Controller } var userId = _userService.GetProperUserId(User); - var results = await _organizationService.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value); + var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value); return new ListResponseModel(results.Select(r => new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs new file mode 100644 index 0000000000..9bfe8f791e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -0,0 +1,186 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IUserRepository _userRepository; + private readonly IEventService _eventService; + private readonly IMailService _mailService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IPushNotificationService _pushNotificationService; + private readonly IPushRegistrationService _pushRegistrationService; + private readonly IPolicyService _policyService; + private readonly IDeviceRepository _deviceRepository; + + public ConfirmOrganizationUserCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + IEventService eventService, + IMailService mailService, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IPushNotificationService pushNotificationService, + IPushRegistrationService pushRegistrationService, + IPolicyService policyService, + IDeviceRepository deviceRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _userRepository = userRepository; + _eventService = eventService; + _mailService = mailService; + _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _pushNotificationService = pushNotificationService; + _pushRegistrationService = pushRegistrationService; + _policyService = policyService; + _deviceRepository = deviceRepository; + } + + public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, + Guid confirmingUserId) + { + var result = await ConfirmUsersAsync( + organizationId, + new Dictionary() { { organizationUserId, key } }, + confirmingUserId); + + if (!result.Any()) + { + throw new BadRequestException("User not valid."); + } + + var (orgUser, error) = result[0]; + if (error != "") + { + throw new BadRequestException(error); + } + return orgUser; + } + + public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId) + { + var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); + var validSelectedOrganizationUsers = selectedOrganizationUsers + .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null) + .ToList(); + + if (!validSelectedOrganizationUsers.Any()) + { + return new List>(); + } + + var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList(); + + var organization = await _organizationRepository.GetByIdAsync(organizationId); + var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); + var users = await _userRepository.GetManyAsync(validSelectedUserIds); + var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); + + var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u); + var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value) + .ToDictionary(u => u.Key, u => u.ToList()); + + var succeededUsers = new List(); + var result = new List>(); + + foreach (var user in users) + { + if (!keyedFilteredUsers.ContainsKey(user.Id)) + { + continue; + } + var orgUser = keyedFilteredUsers[user.Id]; + var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List()); + try + { + if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin + || orgUser.Type == OrganizationUserType.Owner)) + { + // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this. + var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id); + if (adminCount > 0) + { + throw new BadRequestException("User can only be an admin of one free organization."); + } + } + + var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; + await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); + orgUser.Status = OrganizationUserStatusType.Confirmed; + orgUser.Key = keys[orgUser.Id]; + orgUser.Email = null; + + await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); + succeededUsers.Add(orgUser); + result.Add(Tuple.Create(orgUser, "")); + } + catch (BadRequestException e) + { + result.Add(Tuple.Create(orgUser, e.Message)); + } + } + + await _organizationUserRepository.ReplaceManyAsync(succeededUsers); + + return result; + } + + private async Task CheckPoliciesAsync(Guid organizationId, User user, + ICollection userOrgs, bool twoFactorEnabled) + { + // Enforce Two Factor Authentication Policy for this organization + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) + .Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !twoFactorEnabled) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + + var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); + var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); + var otherSingleOrgPolicies = + singleOrgPolicies.Where(p => p.OrganizationId != organizationId); + // Enforce Single Organization Policy for this organization + if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) + { + throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); + } + // Enforce Single Organization Policy of other organizations user is a member of + if (otherSingleOrgPolicies.Any()) + { + throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); + } + } + + private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) + { + var devices = await GetUserDeviceIdsAsync(userId); + await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, + organizationId.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(userId); + } + + private async Task> GetUserDeviceIdsAsync(Guid userId) + { + var devices = await _deviceRepository.GetManyByUserIdAsync(userId); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs new file mode 100644 index 0000000000..302ee0901d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs @@ -0,0 +1,30 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +/// +/// Command to confirm organization users who have accepted their invitations. +/// +public interface IConfirmOrganizationUserCommand +{ + /// + /// Confirms a single organization user who has accepted their invitation. + /// + /// The ID of the organization. + /// The ID of the organization user to confirm. + /// The encrypted organization key for the user. + /// The ID of the user performing the confirmation. + /// The confirmed organization user. + /// Thrown when the user is not valid or cannot be confirmed. + Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); + + /// + /// Confirms multiple organization users who have accepted their invitations. + /// + /// The ID of the organization. + /// A dictionary mapping organization user IDs to their encrypted organization keys. + /// The ID of the user performing the confirmation. + /// A list of tuples containing the organization user and an error message (if any). + Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId); +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index dacb2ab162..476fccb480 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -38,9 +38,6 @@ public interface IOrganizationService IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); - Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); - Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, - Guid confirmingUserId); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); Task ImportAsync(Guid organizationId, IEnumerable groups, IEnumerable newUsers, IEnumerable removeUserExternalIds, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 772b407951..ab5703eaa1 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1127,98 +1127,6 @@ public class OrganizationService : IOrganizationService ); } - public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, - Guid confirmingUserId) - { - var result = await ConfirmUsersAsync( - organizationId, - new Dictionary() { { organizationUserId, key } }, - confirmingUserId); - - if (!result.Any()) - { - throw new BadRequestException("User not valid."); - } - - var (orgUser, error) = result[0]; - if (error != "") - { - throw new BadRequestException(error); - } - return orgUser; - } - - public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, - Guid confirmingUserId) - { - var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); - var validSelectedOrganizationUsers = selectedOrganizationUsers - .Where(u => u.Status == OrganizationUserStatusType.Accepted && u.OrganizationId == organizationId && u.UserId != null) - .ToList(); - - if (!validSelectedOrganizationUsers.Any()) - { - return new List>(); - } - - var validSelectedUserIds = validSelectedOrganizationUsers.Select(u => u.UserId.Value).ToList(); - - var organization = await GetOrgById(organizationId); - var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); - var users = await _userRepository.GetManyAsync(validSelectedUserIds); - var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); - - var keyedFilteredUsers = validSelectedOrganizationUsers.ToDictionary(u => u.UserId.Value, u => u); - var keyedOrganizationUsers = allUsersOrgs.GroupBy(u => u.UserId.Value) - .ToDictionary(u => u.Key, u => u.ToList()); - - var succeededUsers = new List(); - var result = new List>(); - - foreach (var user in users) - { - if (!keyedFilteredUsers.ContainsKey(user.Id)) - { - continue; - } - var orgUser = keyedFilteredUsers[user.Id]; - var orgUsers = keyedOrganizationUsers.GetValueOrDefault(user.Id, new List()); - try - { - if (organization.PlanType == PlanType.Free && (orgUser.Type == OrganizationUserType.Admin - || orgUser.Type == OrganizationUserType.Owner)) - { - // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this. - var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(user.Id); - if (adminCount > 0) - { - throw new BadRequestException("User can only be an admin of one free organization."); - } - } - - var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; - await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); - orgUser.Status = OrganizationUserStatusType.Confirmed; - orgUser.Key = keys[orgUser.Id]; - orgUser.Email = null; - - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); - await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); - succeededUsers.Add(orgUser); - result.Add(Tuple.Create(orgUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(orgUser, e.Message)); - } - } - - await _organizationUserRepository.ReplaceManyAsync(succeededUsers); - - return result; - } - internal async Task<(bool canScale, string failureReason)> CanScaleAsync( Organization organization, int seatsToAdd) @@ -1305,32 +1213,7 @@ public class OrganizationService : IOrganizationService } } - private async Task CheckPoliciesAsync(Guid organizationId, User user, - ICollection userOrgs, bool twoFactorEnabled) - { - // Enforce Two Factor Authentication Policy for this organization - var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) - .Any(p => p.OrganizationId == organizationId); - if (orgRequiresTwoFactor && !twoFactorEnabled) - { - throw new BadRequestException("User does not have two-step login enabled."); - } - var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); - var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); - var otherSingleOrgPolicies = - singleOrgPolicies.Where(p => p.OrganizationId != organizationId); - // Enforce Single Organization Policy for this organization - if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) - { - throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); - } - // Enforce Single Organization Policy of other organizations user is a member of - if (otherSingleOrgPolicies.Any()) - { - throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); - } - } public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId) { @@ -1640,15 +1523,6 @@ public class OrganizationService : IOrganizationService await _groupRepository.UpdateUsersAsync(group.Id, users); } - private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) - { - var devices = await GetUserDeviceIdsAsync(userId); - await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, - organizationId.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(userId); - } - - private async Task> GetUserDeviceIdsAsync(Guid userId) { var devices = await _deviceRepository.GetManyByUserIdAsync(userId); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 232e04fbd0..e13a06f660 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -116,6 +116,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..06335f668d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -0,0 +1,324 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class ConfirmOrganizationUserCommandTests +{ + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithInvalidStatus_ThrowsBadRequestException(OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + + organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User not valid.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithWrongOrganization_ThrowsBadRequestException(OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, string key, + SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + + organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User not valid.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task ConfirmUserAsync_ToFree_WithExistingAdminOrOwner_ThrowsBadRequestException(OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = PlanType.Free; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + orgUser.Type = userType; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User can only be an admin of one free organization.", exception.Message); + } + + [Theory] + [BitAutoData(PlanType.Custom, OrganizationUserType.Admin)] + [BitAutoData(PlanType.Custom, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)] + [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)] + [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Owner)] + [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Admin)] + [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Owner)] + public async Task ConfirmUserAsync_ToNonFree_WithExistingFreeAdminOrOwner_Succeeds(PlanType planType, OrganizationUserType orgUserType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = planType; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + orgUser.Type = orgUserType; + orgUser.AccessSecretsManager = false; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); + await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); + } + + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromConfirmingOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromOtherOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task ConfirmUserAsync_AsOwnerOrAdmin_WithSingleOrgPolicy_ExcludedViaUserType_Success( + OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Type = userType; + orgUser.Status = OrganizationUserStatusType.Accepted; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + orgUser.AccessSecretsManager = true; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true); + await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorDisabled_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser orgUserAnotherOrg, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains("User does not have two-step login enabled.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorEnabled_Succeeds(Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithMultipleUsers_ReturnsExpectedMixedResults(Organization org, + OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, + OrganizationUser anotherOrgUser, User user1, User user2, User user3, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + var organizationUserRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + + org.PlanType = PlanType.EnterpriseAnnually; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + orgUser3.UserId = user3.Id; + anotherOrgUser.UserId = user3.Id; + var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; + organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); + organizationRepository.GetByIdAsync(org.Id).Returns(org); + userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() + { + (user1.Id, true), + (user2.Id, false), + (user3.Id, true) + }); + singleOrgPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) + .Returns(new[] { singleOrgPolicy }); + organizationUserRepository.GetManyByManyUsersAsync(default) + .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); + + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); + var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); + Assert.Contains("", result[0].Item2); + Assert.Contains("User does not have two-step login enabled.", result[1].Item2); + Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 4c42fdfeb9..82dc0e2ebe 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -24,7 +24,6 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Core.Tokens; @@ -978,306 +977,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true); } - [Theory, BitAutoData] - public async Task ConfirmUser_InvalidStatus(OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser orgUser, string key, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User not valid.", exception.Message); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_WrongOrganization(OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, string key, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByIdAsync(orgUser.Id).Returns(orgUser); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(confirmingUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User not valid.", exception.Message); - } - - [Theory] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task ConfirmUserToFree_AlreadyFreeAdminOrOwner_Throws(OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - - org.PlanType = PlanType.Free; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = user.Id; - orgUser.Type = userType; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User can only be an admin of one free organization.", exception.Message); - } - - [Theory] - [BitAutoData(PlanType.Custom, OrganizationUserType.Admin)] - [BitAutoData(PlanType.Custom, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseAnnually, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseAnnually2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseAnnually2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseMonthly, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseMonthly2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)] - [BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)] - [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsAnnually2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsAnnually2019, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsMonthly, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsMonthly2020, OrganizationUserType.Owner)] - [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Admin)] - [BitAutoData(PlanType.TeamsMonthly2019, OrganizationUserType.Owner)] - public async Task ConfirmUserToNonFree_AlreadyFreeAdminOrOwner_DoesNotThrow(PlanType planType, OrganizationUserType orgUserType, Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - - org.PlanType = planType; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = user.Id; - orgUser.Type = orgUserType; - orgUser.AccessSecretsManager = false; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(orgUser.UserId.Value).Returns(1); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); - await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); - } - - - [Theory, BitAutoData] - public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromConfirmingOrg_Throws(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - singleOrgPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_AsUser_SingleOrgPolicy_AppliedFromOtherOrg_Throws(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message); - } - - [Theory] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task ConfirmUser_AsOwnerOrAdmin_SingleOrgPolicy_ExcludedViaUserType_Success( - OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Type = userType; - orgUser.Status = OrganizationUserStatusType.Accepted; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - orgUser.AccessSecretsManager = true; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true); - await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_TwoFactorPolicy_NotEnabled_Throws(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User does not have two-step login enabled.", exception.Message); - } - - [Theory, BitAutoData] - public async Task ConfirmUser_TwoFactorPolicy_Enabled_Success(Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = user.Id; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); - - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - } - - [Theory, BitAutoData] - public async Task ConfirmUsers_Success(Organization org, - OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, - OrganizationUser anotherOrgUser, User user1, User user2, User user3, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - - org.PlanType = PlanType.EnterpriseAnnually; - orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser1.UserId = user1.Id; - orgUser2.UserId = user2.Id; - orgUser3.UserId = user3.Id; - anotherOrgUser.UserId = user3.Id; - var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() - { - (user1.Id, true), - (user2.Id, false), - (user3.Id, true) - }); - singleOrgPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) - .Returns(new[] { singleOrgPolicy }); - organizationUserRepository.GetManyByManyUsersAsync(default) - .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); - - var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); - var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); - Assert.Contains("", result[0].Item2); - Assert.Contains("User does not have two-step login enabled.", result[1].Item2); - Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); - } - [Theory, BitAutoData] public async Task UpdateOrganizationKeysAsync_WithoutManageResetPassword_Throws(Guid orgId, string publicKey, string privateKey, SutProvider sutProvider) From efd33c3301e0c526172ef8212e6a94edf4b9c887 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 24 Mar 2025 13:33:51 -0400 Subject: [PATCH 26/43] chore: set correct version for upcoming scheduled release (#5550) --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b9b8cf557a..2ede6ad8d1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.3.6 + 2025.3.3 Bit.$(MSBuildProjectName) enable From f1a9545a0082e00cf6f3745543fa7c3b6ca00678 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:48:20 -0400 Subject: [PATCH 27/43] Remove unneeded exclusions (#5478) --- src/Billing/Billing.csproj | 2 -- src/Core/Core.csproj | 2 +- src/Infrastructure.Dapper/Infrastructure.Dapper.csproj | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 50e372791f..f32eccfe8c 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -3,8 +3,6 @@ bitwarden-Billing false - - $(WarningsNotAsErrors);CS9113 diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 2a3edcdc00..ea72f3c785 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -4,7 +4,7 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml - $(WarningsNotAsErrors);CS1570;CS1574;CS8602;CS9113;CS1998;CS8604 + $(WarningsNotAsErrors);CS1570;CS1574;CS9113;CS1998 diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index 19512670ce..c51af39824 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -2,7 +2,7 @@ - $(WarningsNotAsErrors);CS8618;CS4014 + $(WarningsNotAsErrors);CS8618 From 229aecb55cb7ebf5450feb2d0459266384405e1a Mon Sep 17 00:00:00 2001 From: Matt Andreko Date: Mon, 24 Mar 2025 14:20:42 -0400 Subject: [PATCH 28/43] Update SARIF upload to use proper branch (#5534) --- .github/workflows/build.yml | 2 ++ .github/workflows/scan.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b96eeb468..8f125b7811 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -317,6 +317,8 @@ jobs: uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} + sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} + ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} upload: name: Upload diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 1fa5c9587c..fe88782e35 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -49,6 +49,8 @@ jobs: uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 with: sarif_file: cx_result.sarif + sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} + ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} quality: name: Quality scan From 55980e8038daadca5a2831ac7d086e742c393fef Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 25 Mar 2025 15:23:01 +0100 Subject: [PATCH 29/43] [PM-16603] Add userkey rotation v2 (#5204) * Implement userkey rotation v2 * Update request models * Cleanup * Update tests * Improve test * Add tests * Fix formatting * Fix test * Remove whitespace * Fix namespace * Enable nullable on models * Fix build * Add tests and enable nullable on masterpasswordunlockdatamodel * Fix test * Remove rollback * Add tests * Make masterpassword hint optional * Update user query * Add EF test * Improve test * Cleanup * Set masterpassword hint * Remove connection close * Add tests for invalid kdf types * Update test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Fix formatting * Update src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Update src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Update src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Update src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Fix imports * Fix tests * Remove null check * Add rollback --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> --- .../Auth/Controllers/AccountsController.cs | 1 + .../Accounts/MasterPasswordUnlockDataModel.cs | 66 +++++++++ .../AccountsKeyManagementController.cs | 83 ++++++++++- .../Requests/AccountKeysRequestModel.cs | 10 ++ .../RotateAccountKeysAndDataRequestModel.cs | 13 ++ .../Models/Requests/UnlockDataRequestModel.cs | 16 +++ .../Models/Requests/UserDataRequestModel.cs | 12 ++ .../UserServiceCollectionExtensions.cs | 1 + src/Core/Constants.cs | 3 + .../Models/Data/MasterPasswordUnlockData.cs | 34 +++++ .../Models/Data/RotateUserAccountKeysData.cs | 28 ++++ .../UserKey/IRotateUserAccountKeysCommand.cs | 20 +++ .../RotateUserAccountkeysCommand.cs | 134 ++++++++++++++++++ src/Core/Repositories/IUserRepository.cs | 2 + .../Repositories/UserRepository.cs | 55 ++++++- .../Repositories/UserRepository.cs | 47 ++++++ .../AccountsKeyManagementControllerTests.cs | 91 ++++++++++++ .../AccountsKeyManagementControllerTests.cs | 85 +++++++++++ .../Request/MasterPasswordUnlockDataModel.cs | 68 +++++++++ .../RotateUserAccountKeysCommandTests.cs | 120 ++++++++++++++++ .../UserKey/RotateUserKeyCommandTests.cs | 2 +- .../Repositories/UserRepositoryTests.cs | 24 ++++ 22 files changed, 906 insertions(+), 9 deletions(-) create mode 100644 src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs create mode 100644 src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs create mode 100644 src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs create mode 100644 src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs create mode 100644 test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs create mode 100644 test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 6c19049c49..2555a6fe2d 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -355,6 +355,7 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")] [HttpPost("key")] public async Task PostKey([FromBody] UpdateKeyRequestModel model) { diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs new file mode 100644 index 0000000000..ba57788cec --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs @@ -0,0 +1,66 @@ +#nullable enable + +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class MasterPasswordUnlockDataModel : IValidatableObject +{ + public required KdfType KdfType { get; set; } + public required int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + [StrictEmailAddress] + [StringLength(256)] + public required string Email { get; set; } + [StringLength(300)] + public required string MasterKeyAuthenticationHash { get; set; } + [EncryptedString] public required string MasterKeyEncryptedUserKey { get; set; } + [StringLength(50)] + public string? MasterPasswordHint { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (KdfType == KdfType.PBKDF2_SHA256) + { + if (KdfMemory.HasValue || KdfParallelism.HasValue) + { + yield return new ValidationResult("KdfMemory and KdfParallelism must be null for PBKDF2_SHA256", new[] { nameof(KdfMemory), nameof(KdfParallelism) }); + } + } + else if (KdfType == KdfType.Argon2id) + { + if (!KdfMemory.HasValue || !KdfParallelism.HasValue) + { + yield return new ValidationResult("KdfMemory and KdfParallelism must have values for Argon2id", new[] { nameof(KdfMemory), nameof(KdfParallelism) }); + } + } + else + { + yield return new ValidationResult("Invalid KdfType", new[] { nameof(KdfType) }); + } + } + + public MasterPasswordUnlockData ToUnlockData() + { + var data = new MasterPasswordUnlockData + { + KdfType = KdfType, + KdfIterations = KdfIterations, + KdfMemory = KdfMemory, + KdfParallelism = KdfParallelism, + + Email = Email, + + MasterKeyAuthenticationHash = MasterKeyAuthenticationHash, + MasterKeyEncryptedUserKey = MasterKeyEncryptedUserKey, + MasterPasswordHint = MasterPasswordHint + }; + return data; + } + +} diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index b8d5e30949..85e0981f22 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,10 +1,23 @@ #nullable enable +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request; +using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Validators; +using Bit.Api.Tools.Models.Request; +using Bit.Api.Vault.Models.Request; using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,18 +32,45 @@ public class AccountsKeyManagementController : Controller private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand; private readonly IUserService _userService; + private readonly IRotateUserAccountKeysCommand _rotateUserAccountKeysCommand; + private readonly IRotationValidator, IEnumerable> _cipherValidator; + private readonly IRotationValidator, IEnumerable> _folderValidator; + private readonly IRotationValidator, IReadOnlyList> _sendValidator; + private readonly IRotationValidator, IEnumerable> + _emergencyAccessValidator; + private readonly IRotationValidator, + IReadOnlyList> + _organizationUserValidator; + private readonly IRotationValidator, IEnumerable> + _webauthnKeyValidator; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, IOrganizationUserRepository organizationUserRepository, IEmergencyAccessRepository emergencyAccessRepository, - IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand) + IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, + IRotateUserAccountKeysCommand rotateUserKeyCommandV2, + IRotationValidator, IEnumerable> cipherValidator, + IRotationValidator, IEnumerable> folderValidator, + IRotationValidator, IReadOnlyList> sendValidator, + IRotationValidator, IEnumerable> + emergencyAccessValidator, + IRotationValidator, IReadOnlyList> + organizationUserValidator, + IRotationValidator, IEnumerable> webAuthnKeyValidator) { _userService = userService; _featureService = featureService; _regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand; _organizationUserRepository = organizationUserRepository; _emergencyAccessRepository = emergencyAccessRepository; + _rotateUserAccountKeysCommand = rotateUserKeyCommandV2; + _cipherValidator = cipherValidator; + _folderValidator = folderValidator; + _sendValidator = sendValidator; + _emergencyAccessValidator = emergencyAccessValidator; + _organizationUserValidator = organizationUserValidator; + _webauthnKeyValidator = webAuthnKeyValidator; } [HttpPost("regenerate-keys")] @@ -47,4 +87,45 @@ public class AccountsKeyManagementController : Controller await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id), usersOrganizationAccounts, designatedEmergencyAccess); } + + + [HttpPost("rotate-user-account-keys")] + public async Task RotateUserAccountKeysAsync([FromBody] RotateUserAccountKeysAndDataRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var dataModel = new RotateUserAccountKeysData + { + OldMasterKeyAuthenticationHash = model.OldMasterKeyAuthenticationHash, + + UserKeyEncryptedAccountPrivateKey = model.AccountKeys.UserKeyEncryptedAccountPrivateKey, + AccountPublicKey = model.AccountKeys.AccountPublicKey, + + MasterPasswordUnlockData = model.AccountUnlockData.MasterPasswordUnlockData.ToUnlockData(), + EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.AccountUnlockData.EmergencyAccessUnlockData), + OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData), + WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData), + + Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers), + Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders), + Sends = await _sendValidator.ValidateAsync(user, model.AccountData.Sends), + }; + + var result = await _rotateUserAccountKeysCommand.RotateUserAccountKeysAsync(user, dataModel); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } diff --git a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs b/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs new file mode 100644 index 0000000000..7c7de4d210 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs @@ -0,0 +1,10 @@ +#nullable enable +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class AccountKeysRequestModel +{ + [EncryptedString] public required string UserKeyEncryptedAccountPrivateKey { get; set; } + public required string AccountPublicKey { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs new file mode 100644 index 0000000000..b0b19e2bd3 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs @@ -0,0 +1,13 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class RotateUserAccountKeysAndDataRequestModel +{ + [StringLength(300)] + public required string OldMasterKeyAuthenticationHash { get; set; } + public required UnlockDataRequestModel AccountUnlockData { get; set; } + public required AccountKeysRequestModel AccountKeys { get; set; } + public required AccountDataRequestModel AccountData { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs new file mode 100644 index 0000000000..5156e2a655 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs @@ -0,0 +1,16 @@ +#nullable enable +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Auth.Models.Request.WebAuthn; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class UnlockDataRequestModel +{ + // All methods to get to the userkey + public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; } + public required IEnumerable EmergencyAccessUnlockData { get; set; } + public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } + public required IEnumerable PasskeyUnlockData { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs new file mode 100644 index 0000000000..f854d82bcc --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/UserDataRequestModel.cs @@ -0,0 +1,12 @@ +#nullable enable +using Bit.Api.Tools.Models.Request; +using Bit.Api.Vault.Models.Request; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class AccountDataRequestModel +{ + public required IEnumerable Ciphers { get; set; } + public required IEnumerable Folders { get; set; } + public required IEnumerable Sends { get; set; } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index df102c855f..16a0ef9805 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static class UserServiceCollectionExtensions public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); + services.AddScoped(); } private static void AddUserPasswordCommands(this IServiceCollection services) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0ae9f1d8d7..e41391b173 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,6 +168,9 @@ public static class FeatureFlagKeys public const string Argon2Default = "argon2-default"; public const string UsePricingService = "use-pricing-service"; public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; + public const string UserkeyRotationV2 = "userkey-rotation-v2"; + public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android"; + public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios"; public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner"; public const string SingleTapPasskeyCreation = "single-tap-passkey-creation"; public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs new file mode 100644 index 0000000000..0ddfc03190 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -0,0 +1,34 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordUnlockData +{ + public KdfType KdfType { get; set; } + public int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + public required string Email { get; set; } + public required string MasterKeyAuthenticationHash { get; set; } + public required string MasterKeyEncryptedUserKey { get; set; } + public string? MasterPasswordHint { get; set; } + + public bool ValidateForUser(User user) + { + if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + { + return false; + } + else if (Email != user.Email) + { + return false; + } + else + { + return true; + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs new file mode 100644 index 0000000000..7cb1c273a3 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -0,0 +1,28 @@ +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Tools.Entities; +using Bit.Core.Vault.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class RotateUserAccountKeysData +{ + // Authentication for this requests + public string OldMasterKeyAuthenticationHash { get; set; } + + // Other keys encrypted by the userkey + public string UserKeyEncryptedAccountPrivateKey { get; set; } + public string AccountPublicKey { get; set; } + + // All methods to get to the userkey + public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public IEnumerable EmergencyAccesses { get; set; } + public IReadOnlyList OrganizationUsers { get; set; } + public IEnumerable WebAuthnKeys { get; set; } + + // User vault data encrypted by the userkey + public IEnumerable Ciphers { get; set; } + public IEnumerable Folders { get; set; } + public IReadOnlyList Sends { get; set; } +} diff --git a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs new file mode 100644 index 0000000000..ec40e7031d --- /dev/null +++ b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.UserKey; + +/// +/// Responsible for rotation of a user key and updating database with re-encrypted data +/// +public interface IRotateUserAccountKeysCommand +{ + /// + /// Sets a new user key and updates all encrypted data. + /// + /// All necessary information for rotation. If data is not included, this will lead to the change being rejected. + /// An IdentityResult for verification of the master password hash + /// User must be provided. + /// User KDF settings and email must match the model provided settings. + Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model); +} diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs new file mode 100644 index 0000000000..f4dcf31d5c --- /dev/null +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs @@ -0,0 +1,134 @@ +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Repositories; +using Bit.Core.Vault.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.UserKey.Implementations; + +/// +public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand +{ + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly ICipherRepository _cipherRepository; + private readonly IFolderRepository _folderRepository; + private readonly ISendRepository _sendRepository; + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPushNotificationService _pushService; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IWebAuthnCredentialRepository _credentialRepository; + private readonly IPasswordHasher _passwordHasher; + + /// + /// Instantiates a new + /// + /// Master password hash validation + /// Updates user keys and re-encrypted data if needed + /// Provides a method to update re-encrypted cipher data + /// Provides a method to update re-encrypted folder data + /// Provides a method to update re-encrypted send data + /// Provides a method to update re-encrypted emergency access data + /// Provides a method to update re-encrypted organization user data + /// Hashes the new master password + /// Logs out user from other devices after successful rotation + /// Provides a password mismatch error if master password hash validation fails + /// Provides a method to update re-encrypted WebAuthn keys + public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository, + ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository, + IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, + IPasswordHasher passwordHasher, + IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository) + { + _userService = userService; + _userRepository = userRepository; + _cipherRepository = cipherRepository; + _folderRepository = folderRepository; + _sendRepository = sendRepository; + _emergencyAccessRepository = emergencyAccessRepository; + _organizationUserRepository = organizationUserRepository; + _pushService = pushService; + _identityErrorDescriber = errors; + _credentialRepository = credentialRepository; + _passwordHasher = passwordHasher; + } + + /// + public async Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (!await _userService.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + var now = DateTime.UtcNow; + user.RevisionDate = user.AccountRevisionDate = now; + user.LastKeyRotationDate = now; + user.SecurityStamp = Guid.NewGuid().ToString(); + + if ( + !model.MasterPasswordUnlockData.ValidateForUser(user) + ) + { + throw new InvalidOperationException("The provided master password unlock data is not valid for this user."); + } + if ( + model.AccountPublicKey != user.PublicKey + ) + { + throw new InvalidOperationException("The provided account public key does not match the user's current public key, and changing the account asymmetric keypair is currently not supported during key rotation."); + } + + user.Key = model.MasterPasswordUnlockData.MasterKeyEncryptedUserKey; + user.PrivateKey = model.UserKeyEncryptedAccountPrivateKey; + user.MasterPassword = _passwordHasher.HashPassword(user, model.MasterPasswordUnlockData.MasterKeyAuthenticationHash); + user.MasterPasswordHint = model.MasterPasswordUnlockData.MasterPasswordHint; + + List saveEncryptedDataActions = new(); + if (model.Ciphers.Any()) + { + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); + } + + if (model.Folders.Any()) + { + saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, model.Folders)); + } + + if (model.Sends.Any()) + { + saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, model.Sends)); + } + + if (model.EmergencyAccesses.Any()) + { + saveEncryptedDataActions.Add( + _emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses)); + } + + if (model.OrganizationUsers.Any()) + { + saveEncryptedDataActions.Add( + _organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers)); + } + + if (model.WebAuthnKeys.Any()) + { + saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys)); + } + + await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions); + await _pushService.PushLogOutAsync(user.Id); + return IdentityResult.Success; + } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 040e6e1f49..0e59b9998f 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -32,5 +32,7 @@ public interface IUserRepository : IRepository /// Registered database calls to update re-encrypted data. Task UpdateUserKeyAndEncryptedDataAsync(User user, IEnumerable updateDataActions); + Task UpdateUserKeyAndEncryptedDataV2Async(User user, + IEnumerable updateDataActions); Task DeleteManyAsync(IEnumerable users); } diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 227a7c03e5..28478a0c41 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -254,6 +254,42 @@ public class UserRepository : Repository, IUserRepository } + public async Task UpdateUserKeyAndEncryptedDataV2Async( + User user, + IEnumerable updateDataActions) + { + await using var connection = new SqlConnection(ConnectionString); + connection.Open(); + + await using var transaction = connection.BeginTransaction(); + try + { + user.AccountRevisionDate = user.RevisionDate; + + ProtectData(user); + await connection.ExecuteAsync( + $"[{Schema}].[{Table}_Update]", + user, + transaction: transaction, + commandType: CommandType.StoredProcedure); + + // Update re-encrypted data + foreach (var action in updateDataActions) + { + await action(connection, transaction); + } + transaction.Commit(); + } + catch + { + transaction.Rollback(); + UnprotectData(user); + throw; + } + UnprotectData(user); + } + + public async Task> GetManyAsync(IEnumerable ids) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) @@ -295,6 +331,18 @@ public class UserRepository : Repository, IUserRepository var originalKey = user.Key; // Protect values + ProtectData(user); + + // Save + await saveTask(); + + // Restore original values + user.MasterPassword = originalMasterPassword; + user.Key = originalKey; + } + + private void ProtectData(User user) + { if (!user.MasterPassword?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false) { user.MasterPassword = string.Concat(Constants.DatabaseFieldProtectedPrefix, @@ -306,13 +354,6 @@ public class UserRepository : Repository, IUserRepository user.Key = string.Concat(Constants.DatabaseFieldProtectedPrefix, _dataProtector.Protect(user.Key!)); } - - // Save - await saveTask(); - - // Restore original values - user.MasterPassword = originalMasterPassword; - user.Key = originalKey; } private void UnprotectData(User? user) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index cbfefb6483..127646ed59 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -170,6 +170,7 @@ public class UserRepository : Repository, IUserR entity.SecurityStamp = user.SecurityStamp; entity.Key = user.Key; + entity.PrivateKey = user.PrivateKey; entity.LastKeyRotationDate = user.LastKeyRotationDate; entity.AccountRevisionDate = user.AccountRevisionDate; @@ -194,6 +195,52 @@ public class UserRepository : Repository, IUserR } + + public async Task UpdateUserKeyAndEncryptedDataV2Async(Core.Entities.User user, + IEnumerable updateDataActions) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Update user + var userEntity = await dbContext.Users.FindAsync(user.Id); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(user)); + } + + userEntity.SecurityStamp = user.SecurityStamp; + userEntity.Key = user.Key; + userEntity.PrivateKey = user.PrivateKey; + + userEntity.Kdf = user.Kdf; + userEntity.KdfIterations = user.KdfIterations; + userEntity.KdfMemory = user.KdfMemory; + userEntity.KdfParallelism = user.KdfParallelism; + + userEntity.Email = user.Email; + + userEntity.MasterPassword = user.MasterPassword; + userEntity.MasterPasswordHint = user.MasterPasswordHint; + + userEntity.LastKeyRotationDate = user.LastKeyRotationDate; + userEntity.AccountRevisionDate = user.AccountRevisionDate; + userEntity.RevisionDate = user.RevisionDate; + + await dbContext.SaveChangesAsync(); + + // Update re-encrypted data + foreach (var action in updateDataActions) + { + // connection and transaction aren't used in EF + await action(); + } + + await transaction.CommitAsync(); + } + public async Task> GetManyAsync(IEnumerable ids) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index ec7ca37460..7c05e1d680 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -2,12 +2,18 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.Tools.Models.Request; +using Bit.Api.Vault.Models; +using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Vault.Enums; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; using Xunit; namespace Bit.Api.IntegrationTest.KeyManagement.Controllers; @@ -23,6 +29,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture _passwordHasher; private string _ownerEmail = null!; public AccountsKeyManagementControllerTests(ApiApplicationFactory factory) @@ -35,6 +42,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(); _emergencyAccessRepository = _factory.GetService(); _organizationUserRepository = _factory.GetService(); + _passwordHasher = _factory.GetService>(); } public async Task InitializeAsync() @@ -161,4 +169,87 @@ public class AccountsKeyManagementControllerTests : IClassFixture sutProvider, + RotateUserAccountKeysAndDataRequestModel data, User user) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + await sutProvider.Sut.RotateUserAccountKeysAsync(data); + + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.EmergencyAccessUnlockData)); + await sutProvider.GetDependency, IReadOnlyList>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.OrganizationAccountRecoveryUnlockData)); + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountUnlockData.PasskeyUnlockData)); + + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Ciphers)); + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Folders)); + await sutProvider.GetDependency, IReadOnlyList>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(data.AccountData.Sends)); + + await sutProvider.GetDependency().Received(1) + .RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is(d => + d.OldMasterKeyAuthenticationHash == data.OldMasterKeyAuthenticationHash + + && d.MasterPasswordUnlockData.KdfType == data.AccountUnlockData.MasterPasswordUnlockData.KdfType + && d.MasterPasswordUnlockData.KdfIterations == data.AccountUnlockData.MasterPasswordUnlockData.KdfIterations + && d.MasterPasswordUnlockData.KdfMemory == data.AccountUnlockData.MasterPasswordUnlockData.KdfMemory + && d.MasterPasswordUnlockData.KdfParallelism == data.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism + && d.MasterPasswordUnlockData.Email == data.AccountUnlockData.MasterPasswordUnlockData.Email + + && d.MasterPasswordUnlockData.MasterKeyAuthenticationHash == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyAuthenticationHash + && d.MasterPasswordUnlockData.MasterKeyEncryptedUserKey == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey + + && d.AccountPublicKey == data.AccountKeys.AccountPublicKey + && d.UserKeyEncryptedAccountPrivateKey == data.AccountKeys.UserKeyEncryptedAccountPrivateKey + )); + } + + + [Theory] + [BitAutoData] + public async Task RotateUserKeyNoUser_Throws(SutProvider sutProvider, + RotateUserAccountKeysAndDataRequestModel data) + { + User? user = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + await Assert.ThrowsAsync(() => sutProvider.Sut.RotateUserAccountKeysAsync(data)); + } + + [Theory] + [BitAutoData] + public async Task RotateUserKeyWrongData_Throws(SutProvider sutProvider, + RotateUserAccountKeysAndDataRequestModel data, User user, IdentityErrorDescriber _identityErrorDescriber) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + sutProvider.GetDependency().RotateUserAccountKeysAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch())); + try + { + await sutProvider.Sut.RotateUserAccountKeysAsync(data); + Assert.Fail("Should have thrown"); + } + catch (BadRequestException ex) + { + Assert.NotEmpty(ex.ModelState.Values); + } + } } diff --git a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs new file mode 100644 index 0000000000..4c78c7015a --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs @@ -0,0 +1,68 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class MasterPasswordUnlockDataModelTests +{ + + readonly string _mockEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 5000, null, null)] + [InlineData(KdfType.PBKDF2_SHA256, 100000, null, null)] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var model = new MasterPasswordUnlockDataModel + { + KdfType = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Email = "example@example.com", + MasterKeyAuthenticationHash = "hash", + MasterKeyEncryptedUserKey = _mockEncryptedString, + MasterPasswordHint = "hint" + }; + var result = Validate(model); + Assert.Empty(result); + } + + [Theory] + [InlineData(KdfType.Argon2id, 1, null, 1)] + [InlineData(KdfType.Argon2id, 1, 64, null)] + [InlineData(KdfType.PBKDF2_SHA256, 5000, 0, null)] + [InlineData(KdfType.PBKDF2_SHA256, 5000, null, 0)] + [InlineData(KdfType.PBKDF2_SHA256, 5000, 0, 0)] + [InlineData((KdfType)2, 100000, null, null)] + [InlineData((KdfType)2, 2, 64, 4)] + public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var model = new MasterPasswordUnlockDataModel + { + KdfType = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Email = "example@example.com", + MasterKeyAuthenticationHash = "hash", + MasterKeyEncryptedUserKey = _mockEncryptedString, + MasterPasswordHint = "hint" + }; + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + private static List Validate(MasterPasswordUnlockDataModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs new file mode 100644 index 0000000000..e677814fc1 --- /dev/null +++ b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs @@ -0,0 +1,120 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.UserKey.Implementations; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.UserKey; + +[SutProviderCustomize] +public class RotateUserAccountKeysCommandTests +{ + [Theory, BitAutoData] + public async Task RejectsWrongOldMasterPassword(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Email = model.MasterPasswordUnlockData.Email; + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(false); + + var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model); + + Assert.NotEqual(IdentityResult.Success, result); + } + [Theory, BitAutoData] + public async Task ThrowsWhenUserIsNull(SutProvider sutProvider, + RotateUserAccountKeysData model) + { + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(null, model)); + } + [Theory, BitAutoData] + public async Task RejectsEmailChange(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.MasterPasswordUnlockData.Email = user.Email + ".different-domain"; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id; + model.MasterPasswordUnlockData.KdfIterations = 3; + model.MasterPasswordUnlockData.KdfMemory = 64; + model.MasterPasswordUnlockData.KdfParallelism = 4; + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model)); + } + + [Theory, BitAutoData] + public async Task RejectsKdfChange(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.MasterPasswordUnlockData.Email = user.Email; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.PBKDF2_SHA256; + model.MasterPasswordUnlockData.KdfIterations = 600000; + model.MasterPasswordUnlockData.KdfMemory = null; + model.MasterPasswordUnlockData.KdfParallelism = null; + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model)); + } + + + [Theory, BitAutoData] + public async Task RejectsPublicKeyChange(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.PublicKey = "old-public"; + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.AccountPublicKey = "new-public"; + model.MasterPasswordUnlockData.Email = user.Email; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id; + model.MasterPasswordUnlockData.KdfIterations = 3; + model.MasterPasswordUnlockData.KdfMemory = 64; + model.MasterPasswordUnlockData.KdfParallelism = 4; + + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model)); + } + + [Theory, BitAutoData] + public async Task RotatesCorrectly(SutProvider sutProvider, User user, + RotateUserAccountKeysData model) + { + user.Kdf = Enums.KdfType.Argon2id; + user.KdfIterations = 3; + user.KdfMemory = 64; + user.KdfParallelism = 4; + + model.MasterPasswordUnlockData.Email = user.Email; + model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id; + model.MasterPasswordUnlockData.KdfIterations = 3; + model.MasterPasswordUnlockData.KdfMemory = 64; + model.MasterPasswordUnlockData.KdfParallelism = 4; + + model.AccountPublicKey = user.PublicKey; + + sutProvider.GetDependency().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash) + .Returns(true); + + var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model); + + Assert.Equal(IdentityResult.Success, result); + } +} diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs index 53263d8805..000fa7e90c 100644 --- a/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs +++ b/test/Core.Test/KeyManagement/UserKey/RotateUserKeyCommandTests.cs @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity; using NSubstitute; using Xunit; -namespace Bit.Core.Test.KeyManagement.UserFeatures.UserKey; +namespace Bit.Core.Test.KeyManagement.UserKey; [SutProviderCustomize] public class RotateUserKeyCommandTests diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs index 066a550fa8..151bd47c44 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/UserRepositoryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; @@ -289,4 +290,27 @@ public class UserRepositoryTests var distinctItems = returnedList.Distinct(equalityComparer); Assert.True(!distinctItems.Skip(1).Any()); } + + [CiSkippedTheory, EfUserAutoData] + public async Task UpdateUserKeyAndEncryptedDataAsync_Works_DataMatches(User user, SqlRepo.UserRepository sqlUserRepo) + { + var sqlUser = await sqlUserRepo.CreateAsync(user); + sqlUser.Kdf = KdfType.PBKDF2_SHA256; + sqlUser.KdfIterations = 6_000_000; + sqlUser.KdfMemory = 7_000_000; + sqlUser.KdfParallelism = 8_000_000; + sqlUser.MasterPassword = "masterPasswordHash"; + sqlUser.MasterPasswordHint = "masterPasswordHint"; + sqlUser.Email = "example@example.com"; + + await sqlUserRepo.UpdateUserKeyAndEncryptedDataV2Async(sqlUser, []); + var updatedUser = await sqlUserRepo.GetByIdAsync(sqlUser.Id); + Assert.Equal(sqlUser.Kdf, updatedUser.Kdf); + Assert.Equal(sqlUser.KdfIterations, updatedUser.KdfIterations); + Assert.Equal(sqlUser.KdfMemory, updatedUser.KdfMemory); + Assert.Equal(sqlUser.KdfParallelism, updatedUser.KdfParallelism); + Assert.Equal(sqlUser.MasterPassword, updatedUser.MasterPassword); + Assert.Equal(sqlUser.MasterPasswordHint, updatedUser.MasterPasswordHint); + Assert.Equal(sqlUser.Email, updatedUser.Email); + } } From d563f3f78ab14a04aded397245d0c02a24531da6 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:16:06 -0500 Subject: [PATCH 30/43] Fix logic in text handlebars template (#5542) --- .../SecurityTasksNotification.text.hbs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs index f5493e4503..f6c0921165 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs @@ -6,12 +6,14 @@ Launch the Bitwarden extension to review your at-risk passwords. Review at-risk passwords ({{{ReviewPasswordsUrl}}}) -{{#if (eq (length AdminOwnerEmails) 1)}} -This request was initiated by {{AdminOwnerEmails.[0]}}. -{{else}} -This request was initiated by -{{#each AdminOwnerEmails}} - {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}} -{{/each}}. +{{#if AdminOwnerEmails.[0]}} + {{#if AdminOwnerEmails.[1]}} + This request was initiated by + {{#each AdminOwnerEmails}} + {{#if @last}}and {{/if}}{{this}}{{#unless @last}}, {{/unless}} + {{/each}}. + {{else}} + This request was initiated by {{AdminOwnerEmails.[0]}}. + {{/if}} {{/if}} {{/SecurityTasksHtmlLayout}} From f04a3d638b05958957db4e5823d7fb8a511992fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 26 Mar 2025 09:40:13 +0000 Subject: [PATCH 31/43] [PM-18235] Add PersonalOwnershipPolicyRequirement (#5439) * Add PersonalOwnershipPolicyRequirement for managing personal ownership policy * Add tests for PersonalOwnershipPolicyRequirement * Register PersonalOwnershipPolicyRequirement in policy requirement factory * Update ImportCiphersCommand to check PersonalOwnershipPolicyRequirement if the PolicyRequirements flag is enabled Update unit tests * Update CipherService to support PersonalOwnershipPolicyRequirement with feature flag - Add support for checking personal ownership policy using PolicyRequirementQuery when feature flag is enabled - Update CipherService constructor to inject new dependencies - Add tests for personal vault restrictions with and without feature flag * Clean up redundant "Arrange", "Act", and "Assert" comments in test methods * Refactor PersonalOwnershipPolicyRequirementTests method names for clarity - Improve test method names to better describe their purpose and behavior - Rename methods to follow a more descriptive naming convention - No functional changes to the test logic * Remove commented code explaining policy check * Refactor PersonalOwnership Policy Requirement implementation - Add PersonalOwnershipPolicyRequirementFactory to replace static Create method - Simplify policy requirement creation logic - Update PolicyServiceCollectionExtensions to register new factory - Update ImportCiphersCommand to use correct user ID parameter - Remove redundant PersonalOwnershipPolicyRequirementTests * Remove redundant PersonalOwnershipPolicyRequirementTests * Remove unnecessary tests from PersonalOwnershipPolicyRequirementFactoryTests --- .../PersonalOwnershipPolicyRequirement.cs | 26 +++++ .../PolicyServiceCollectionExtensions.cs | 1 + .../ImportFeatures/ImportCiphersCommand.cs | 20 +++- .../Services/Implementations/CipherService.cs | 18 +++- ...lOwnershipPolicyRequirementFactoryTests.cs | 31 ++++++ .../ImportCiphersAsyncCommandTests.cs | 58 ++++++++++- .../Vault/Services/CipherServiceTests.cs | 96 +++++++++++++++++++ 7 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirementFactoryTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs new file mode 100644 index 0000000000..6f3f017bb9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Disable Personal Ownership policy. +/// +public class PersonalOwnershipPolicyRequirement : IPolicyRequirement +{ + /// + /// Indicates whether Personal Ownership is disabled for the user. If true, members are required to save items to an organization. + /// + public bool DisablePersonalOwnership { get; init; } +} + +public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.PersonalOwnership; + + public override PersonalOwnershipPolicyRequirement Create(IEnumerable policyDetails) + { + var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() }; + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index d386006ad2..d330c57291 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -34,5 +34,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, DisableSendPolicyRequirementFactory>(); services.AddScoped, SendOptionsPolicyRequirementFactory>(); services.AddScoped, ResetPasswordPolicyRequirementFactory>(); + services.AddScoped, PersonalOwnershipPolicyRequirementFactory>(); } } diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index 59d3e5be34..3c58dca183 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -1,10 +1,13 @@ using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Tools.Enums; using Bit.Core.Tools.ImportFeatures.Interfaces; using Bit.Core.Tools.Models.Business; @@ -26,7 +29,8 @@ public class ImportCiphersCommand : IImportCiphersCommand private readonly ICollectionRepository _collectionRepository; private readonly IReferenceEventService _referenceEventService; private readonly ICurrentContext _currentContext; - + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IFeatureService _featureService; public ImportCiphersCommand( ICipherRepository cipherRepository, @@ -37,7 +41,9 @@ public class ImportCiphersCommand : IImportCiphersCommand IPushNotificationService pushService, IPolicyService policyService, IReferenceEventService referenceEventService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -48,9 +54,10 @@ public class ImportCiphersCommand : IImportCiphersCommand _policyService = policyService; _referenceEventService = referenceEventService; _currentContext = currentContext; + _policyRequirementQuery = policyRequirementQuery; + _featureService = featureService; } - public async Task ImportIntoIndividualVaultAsync( List folders, List ciphers, @@ -58,8 +65,11 @@ public class ImportCiphersCommand : IImportCiphersCommand Guid importingUserId) { // Make sure the user can save new ciphers to their personal vault - var anyPersonalOwnershipPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership); - if (anyPersonalOwnershipPolicies) + var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + ? (await _policyRequirementQuery.GetAsync(importingUserId)).DisablePersonalOwnership + : await _policyService.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.PersonalOwnership); + + if (isPersonalVaultRestricted) { throw new BadRequestException("You cannot import items into your personal vault because you are " + "a member of an organization which forbids it."); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index a315528e59..b9daafe599 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -1,5 +1,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Enums; @@ -41,6 +43,8 @@ public class CipherService : ICipherService private readonly IReferenceEventService _referenceEventService; private readonly ICurrentContext _currentContext; private readonly IGetCipherPermissionsForUserQuery _getCipherPermissionsForUserQuery; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IFeatureService _featureService; public CipherService( ICipherRepository cipherRepository, @@ -58,7 +62,9 @@ public class CipherService : ICipherService GlobalSettings globalSettings, IReferenceEventService referenceEventService, ICurrentContext currentContext, - IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery) + IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -76,6 +82,8 @@ public class CipherService : ICipherService _referenceEventService = referenceEventService; _currentContext = currentContext; _getCipherPermissionsForUserQuery = getCipherPermissionsForUserQuery; + _policyRequirementQuery = policyRequirementQuery; + _featureService = featureService; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, @@ -143,9 +151,11 @@ public class CipherService : ICipherService } else { - // Make sure the user can save new ciphers to their personal vault - var anyPersonalOwnershipPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership); - if (anyPersonalOwnershipPolicies) + var isPersonalVaultRestricted = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + ? (await _policyRequirementQuery.GetAsync(savingUserId)).DisablePersonalOwnership + : await _policyService.AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership); + + if (isPersonalVaultRestricted) { throw new BadRequestException("Due to an Enterprise Policy, you are restricted from saving items to your personal vault."); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirementFactoryTests.cs new file mode 100644 index 0000000000..2ce75ca61e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirementFactoryTests.cs @@ -0,0 +1,31 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +[SutProviderCustomize] +public class PersonalOwnershipPolicyRequirementFactoryTests +{ + [Theory, BitAutoData] + public void DisablePersonalOwnership_WithNoPolicies_ReturnsFalse(SutProvider sutProvider) + { + var actual = sutProvider.Sut.Create([]); + + Assert.False(actual.DisablePersonalOwnership); + } + + [Theory, BitAutoData] + public void DisablePersonalOwnership_WithPersonalOwnershipPolicies_ReturnsTrue( + [PolicyDetails(PolicyType.PersonalOwnership)] PolicyDetails[] policies, + SutProvider sutProvider) + { + var actual = sutProvider.Sut.Create(policies); + + Assert.True(actual.DisablePersonalOwnership); + } +} diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index 5e7a30d814..89e6d152cc 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -1,10 +1,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Test.AutoFixture.CipherFixtures; using Bit.Core.Tools.Enums; using Bit.Core.Tools.ImportFeatures; @@ -18,7 +21,6 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; - namespace Bit.Core.Test.Tools.ImportFeatures; [UserCipherCustomize] @@ -51,6 +53,34 @@ public class ImportCiphersAsyncCommandTests await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithPolicyRequirementsEnabled_WithDisablePersonalOwnershipPolicyDisabled_Success( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(importingUserId) + .Returns(new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = false }); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List { new Folder { UserId = importingUserId } }; + + var folderRelationships = new List>(); + + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + await sutProvider.GetDependency().Received(1).CreateAsync(ciphers, Arg.Any>()); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + [Theory, BitAutoData] public async Task ImportIntoIndividualVaultAsync_ThrowsBadRequestException( List folders, @@ -73,6 +103,32 @@ public class ImportCiphersAsyncCommandTests Assert.Equal("You cannot import items into your personal vault because you are a member of an organization which forbids it.", exception.Message); } + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithPolicyRequirementsEnabled_WithDisablePersonalOwnershipPolicyEnabled_ThrowsBadRequestException( + List folders, + List ciphers, + SutProvider sutProvider) + { + var userId = Guid.NewGuid(); + folders.ForEach(f => f.UserId = userId); + ciphers.ForEach(c => c.UserId = userId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = true }); + + var folderRelationships = new List>(); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, userId)); + + Assert.Equal("You cannot import items into your personal vault because you are a member of an organization which forbids it.", exception.Message); + } + [Theory, BitAutoData] public async Task ImportIntoOrganizationalVaultAsync_Success( Organization organization, diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 3ef29146c2..a7dcbddcea 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,5 +1,9 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -107,6 +111,98 @@ public class CipherServiceTests await sutProvider.GetDependency().Received(1).ReplaceAsync(cipherDetails); } + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_PersonalVault_WithDisablePersonalOwnershipPolicyEnabled_Throws( + SutProvider sutProvider, + CipherDetails cipher, + Guid savingUserId) + { + cipher.Id = default; + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership) + .Returns(true); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null)); + Assert.Contains("restricted from saving items to your personal vault", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_PersonalVault_WithDisablePersonalOwnershipPolicyDisabled_Succeeds( + SutProvider sutProvider, + CipherDetails cipher, + Guid savingUserId) + { + cipher.Id = default; + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(savingUserId, PolicyType.PersonalOwnership) + .Returns(false); + + await sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(cipher); + } + + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_PersonalVault_WithPolicyRequirementsEnabled_WithDisablePersonalOwnershipPolicyEnabled_Throws( + SutProvider sutProvider, + CipherDetails cipher, + Guid savingUserId) + { + cipher.Id = default; + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(savingUserId) + .Returns(new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = true }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null)); + Assert.Contains("restricted from saving items to your personal vault", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SaveDetailsAsync_PersonalVault_WithPolicyRequirementsEnabled_WithDisablePersonalOwnershipPolicyDisabled_Succeeds( + SutProvider sutProvider, + CipherDetails cipher, + Guid savingUserId) + { + cipher.Id = default; + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(savingUserId) + .Returns(new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = false }); + + await sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(cipher); + } + [Theory] [BitAutoData("")] [BitAutoData("Correct Time")] From 6f227c31e2be364d0c147b454dd87c21e4179030 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 26 Mar 2025 15:10:35 +0100 Subject: [PATCH 32/43] Sort km feature flags (#5557) --- src/Core/Constants.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e41391b173..c629881458 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -129,19 +129,25 @@ public static class FeatureFlagKeys /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; + /* Key Management Team */ + public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; + public const string SSHAgent = "ssh-agent"; + public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override"; + public const string Argon2Default = "argon2-default"; + public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; + public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; + public const string UserkeyRotationV2 = "userkey-rotation-v2"; + + /* Unsorted */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string DuoRedirect = "duo-redirect"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string EmailVerification = "email-verification"; public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; - public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; - public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; - public const string SSHAgent = "ssh-agent"; - public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; @@ -162,13 +168,10 @@ public static class FeatureFlagKeys public const string NewDeviceVerification = "new-device-verification"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string InlineMenuTotp = "inline-menu-totp"; - public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string AppReviewPrompt = "app-review-prompt"; public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; - public const string Argon2Default = "argon2-default"; public const string UsePricingService = "use-pricing-service"; public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; - public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android"; public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios"; public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner"; From d4b00583728f1e85de3ba8e0b62ee16767842855 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 26 Mar 2025 11:44:05 -0400 Subject: [PATCH 33/43] Organization integrations and configuration database schemas (#5553) * Organization integrations and configuration database schemas * Format EF files --- .../Entities/OrganizationIntegration.cs | 18 + .../OrganizationIntegrationConfiguration.cs | 19 + .../AdminConsole/Enums/IntegrationType.cs | 7 + ...ionConfigurationEntityTypeConfiguration.cs | 17 + ...ationIntegrationEntityTypeConfiguration.cs | 26 + .../Models/OrganizationIntegration.cs | 16 + .../OrganizationIntegrationConfiguration.cs | 16 + ...EventTypeOrganizationIdIntegrationType.sql | 22 + .../dbo/Tables/OrganizationIntegration.sql | 20 + .../OrganizationIntegrationConfiguration.sql | 13 + ...ganizationIntegrationConfigurationView.sql | 6 + .../dbo/Views/OrganizationIntegrationView.sql | 6 + ...2025-03-24_00_OrganizationIntegrations.sql | 101 + ...31708_OrganizationIntegrations.Designer.cs | 3101 ++++++++++++++++ ...20250325231708_OrganizationIntegrations.cs | 89 + .../DatabaseContextModelSnapshot.cs | 92 +- ...31701_OrganizationIntegrations.Designer.cs | 3107 +++++++++++++++++ ...20250325231701_OrganizationIntegrations.cs | 84 + .../DatabaseContextModelSnapshot.cs | 92 +- ...31714_OrganizationIntegrations.Designer.cs | 3090 ++++++++++++++++ ...20250325231714_OrganizationIntegrations.cs | 84 + .../DatabaseContextModelSnapshot.cs | 92 +- 22 files changed, 10106 insertions(+), 12 deletions(-) create mode 100644 src/Core/AdminConsole/Entities/OrganizationIntegration.cs create mode 100644 src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs create mode 100644 src/Core/AdminConsole/Enums/IntegrationType.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql create mode 100644 src/Sql/dbo/Tables/OrganizationIntegration.sql create mode 100644 src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql create mode 100644 src/Sql/dbo/Views/OrganizationIntegrationConfigurationView.sql create mode 100644 src/Sql/dbo/Views/OrganizationIntegrationView.sql create mode 100644 util/Migrator/DbScripts/2025-03-24_00_OrganizationIntegrations.sql create mode 100644 util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.cs create mode 100644 util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.cs create mode 100644 util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.cs diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs new file mode 100644 index 0000000000..18f8be8667 --- /dev/null +++ b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +#nullable enable + +namespace Bit.Core.AdminConsole.Entities; + +public class OrganizationIntegration : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public IntegrationType Type { get; set; } + public string? Configuration { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public void SetNewId() => Id = CoreHelpers.GenerateComb(); +} diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs new file mode 100644 index 0000000000..7592d0c763 --- /dev/null +++ b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs @@ -0,0 +1,19 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +#nullable enable + +namespace Bit.Core.AdminConsole.Entities; + +public class OrganizationIntegrationConfiguration : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationIntegrationId { get; set; } + public EventType EventType { get; set; } + public string? Configuration { get; set; } + public string? Template { get; set; } + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public void SetNewId() => Id = CoreHelpers.GenerateComb(); +} diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs new file mode 100644 index 0000000000..16c7818dee --- /dev/null +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Enums; + +public enum IntegrationType : int +{ + Slack = 1, + Webhook = 2, +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs new file mode 100644 index 0000000000..29712f5e38 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationConfigurationEntityTypeConfiguration.cs @@ -0,0 +1,17 @@ +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations; + +public class OrganizationIntegrationConfigurationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(p => p.Id) + .ValueGeneratedNever(); + + builder.ToTable(nameof(OrganizationIntegrationConfiguration)); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs new file mode 100644 index 0000000000..c2134c1b7d --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationIntegrationEntityTypeConfiguration.cs @@ -0,0 +1,26 @@ +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Configurations; + +public class OrganizationIntegrationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(p => p.Id) + .ValueGeneratedNever(); + + builder + .HasIndex(p => p.OrganizationId) + .IsClustered(false); + + builder + .HasIndex(p => new { p.OrganizationId, p.Type }) + .IsUnique() + .IsClustered(false); + + builder.ToTable(nameof(OrganizationIntegration)); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs new file mode 100644 index 0000000000..db81b81166 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs @@ -0,0 +1,16 @@ +using AutoMapper; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; + +public class OrganizationIntegration : Core.AdminConsole.Entities.OrganizationIntegration +{ + public virtual Organization Organization { get; set; } +} + +public class OrganizationIntegrationMapperProfile : Profile +{ + public OrganizationIntegrationMapperProfile() + { + CreateMap().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs new file mode 100644 index 0000000000..465a49dc02 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs @@ -0,0 +1,16 @@ +using AutoMapper; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; + +public class OrganizationIntegrationConfiguration : Core.AdminConsole.Entities.OrganizationIntegrationConfiguration +{ + public virtual OrganizationIntegration OrganizationIntegration { get; set; } +} + +public class OrganizationIntegrationConfigurationMapperProfile : Profile +{ + public OrganizationIntegrationConfigurationMapperProfile() + { + CreateMap().ReverseMap(); + } +} diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql new file mode 100644 index 0000000000..113aa2e529 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType] + @EventType SMALLINT, + @OrganizationId UNIQUEIDENTIFIER, + @IntegrationType SMALLINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationView] oic + INNER JOIN + [dbo].[OrganizationIntegration] oi ON oi.[Id] = oic.[OrganizationIntegrationId] + WHERE + oic.[EventType] = @EventType + AND + oi.[OrganizationId] = @OrganizationId + AND + oi.[Type] = @IntegrationType +END +GO diff --git a/src/Sql/dbo/Tables/OrganizationIntegration.sql b/src/Sql/dbo/Tables/OrganizationIntegration.sql new file mode 100644 index 0000000000..8ac289c303 --- /dev/null +++ b/src/Sql/dbo/Tables/OrganizationIntegration.sql @@ -0,0 +1,20 @@ +CREATE TABLE [dbo].[OrganizationIntegration] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Type] SMALLINT NOT NULL, + [Configuration] VARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_OrganizationIntegration] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_OrganizationIntegration_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) +); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationIntegration_OrganizationId] + ON [dbo].[OrganizationIntegration]([OrganizationId] ASC); +GO + +CREATE UNIQUE INDEX [IX_OrganizationIntegration_Organization_Type] + ON [dbo].[OrganizationIntegration]([OrganizationId], [Type]); +GO diff --git a/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql b/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql new file mode 100644 index 0000000000..9dbb2341a7 --- /dev/null +++ b/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql @@ -0,0 +1,13 @@ +CREATE TABLE [dbo].[OrganizationIntegrationConfiguration] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationIntegrationId] UNIQUEIDENTIFIER NOT NULL, + [EventType] SMALLINT NOT NULL, + [Configuration] VARCHAR (MAX) NULL, + [Template] VARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_OrganizationIntegrationConfiguration] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_OrganizationIntegrationConfiguration_OrganizationIntegration] FOREIGN KEY ([OrganizationIntegrationId]) REFERENCES [dbo].[OrganizationIntegration] ([Id]) +); +GO diff --git a/src/Sql/dbo/Views/OrganizationIntegrationConfigurationView.sql b/src/Sql/dbo/Views/OrganizationIntegrationConfigurationView.sql new file mode 100644 index 0000000000..4f39fbc8f3 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationIntegrationConfigurationView.sql @@ -0,0 +1,6 @@ +CREATE VIEW [dbo].[OrganizationIntegrationConfigurationView] +AS + SELECT + * + FROM + [dbo].[OrganizationIntegrationConfiguration] diff --git a/src/Sql/dbo/Views/OrganizationIntegrationView.sql b/src/Sql/dbo/Views/OrganizationIntegrationView.sql new file mode 100644 index 0000000000..31e005d5d2 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationIntegrationView.sql @@ -0,0 +1,6 @@ +CREATE VIEW [dbo].[OrganizationIntegrationView] +AS + SELECT + * + FROM + [dbo].[OrganizationIntegration] diff --git a/util/Migrator/DbScripts/2025-03-24_00_OrganizationIntegrations.sql b/util/Migrator/DbScripts/2025-03-24_00_OrganizationIntegrations.sql new file mode 100644 index 0000000000..56d4d465d2 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-24_00_OrganizationIntegrations.sql @@ -0,0 +1,101 @@ +-- OrganizationIntegration + +-- Table +IF OBJECT_ID('[dbo].[OrganizationIntegration]') IS NULL +BEGIN + CREATE TABLE [dbo].[OrganizationIntegration] + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [Type] SMALLINT NOT NULL, + [Configuration] VARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_OrganizationIntegration] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_OrganizationIntegration_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) + ); + + CREATE NONCLUSTERED INDEX [IX_OrganizationIntegration_OrganizationId] + ON [dbo].[OrganizationIntegration]([OrganizationId] ASC); + + CREATE UNIQUE INDEX [IX_OrganizationIntegration_Organization_Type] + ON [dbo].[OrganizationIntegration]([OrganizationId], [Type]); +END +GO + +-- View +IF EXISTS(SELECT * +FROM sys.views +WHERE [Name] = 'OrganizationIntegrationView') +BEGIN + DROP VIEW [dbo].[OrganizationIntegrationView]; +END +GO + +CREATE VIEW [dbo].[OrganizationIntegrationView] +AS + SELECT + * + FROM + [dbo].[OrganizationIntegration] +GO + +-- OrganizationIntegrationConfiguration + +-- Table +IF OBJECT_ID('[dbo].[OrganizationIntegrationConfiguration]') IS NULL +BEGIN + CREATE TABLE [dbo].[OrganizationIntegrationConfiguration] + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationIntegrationId] UNIQUEIDENTIFIER NOT NULL, + [EventType] SMALLINT NOT NULL, + [Configuration] VARCHAR (MAX) NULL, + [Template] VARCHAR (MAX) NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [RevisionDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_OrganizationIntegrationConfiguration] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_OrganizationIntegrationConfiguration_OrganizationIntegration] FOREIGN KEY ([OrganizationIntegrationId]) REFERENCES [dbo].[OrganizationIntegration] ([Id]) + ); +END +GO + +-- View +IF EXISTS(SELECT * +FROM sys.views +WHERE [Name] = 'OrganizationIntegrationConfigurationView') +BEGIN + DROP VIEW [dbo].[OrganizationIntegrationConfigurationView]; +END +GO + +CREATE VIEW [dbo].[OrganizationIntegrationConfigurationView] +AS + SELECT + * + FROM + [dbo].[OrganizationIntegrationConfiguration] +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType] + @EventType SMALLINT, + @OrganizationId UNIQUEIDENTIFIER, + @IntegrationType SMALLINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationView] oic + INNER JOIN + [dbo].[OrganizationIntegration] oi ON oi.[Id] = oic.[OrganizationIntegrationId] + WHERE + oic.[EventType] = @EventType + AND + oi.[OrganizationId] = @OrganizationId + AND + oi.[Type] = @IntegrationType +END +GO diff --git a/util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.Designer.cs b/util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.Designer.cs new file mode 100644 index 0000000000..0968a87104 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.Designer.cs @@ -0,0 +1,3101 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250325231708_OrganizationIntegrations")] + partial class OrganizationIntegrations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasColumnType("longtext"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.cs b/util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.cs new file mode 100644 index 0000000000..d488f3fc4a --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250325231708_OrganizationIntegrations.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class OrganizationIntegrations : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrganizationIntegration", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + OrganizationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Type = table.Column(type: "int", nullable: false), + Configuration = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreationDate = table.Column(type: "datetime(6)", nullable: false), + RevisionDate = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationIntegration", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationIntegration_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "OrganizationIntegrationConfiguration", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + OrganizationIntegrationId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + EventType = table.Column(type: "int", nullable: false), + Configuration = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Template = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreationDate = table.Column(type: "datetime(6)", nullable: false), + RevisionDate = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationIntegrationConfiguration", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationIntegrationConfiguration_OrganizationIntegration~", + column: x => x.OrganizationIntegrationId, + principalTable: "OrganizationIntegration", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegration_OrganizationId", + table: "OrganizationIntegration", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegration_OrganizationId_Type", + table: "OrganizationIntegration", + columns: new[] { "OrganizationId", "Type" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegrationConfiguration_OrganizationIntegration~", + table: "OrganizationIntegrationConfiguration", + column: "OrganizationIntegrationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrganizationIntegrationConfiguration"); + + migrationBuilder.DropTable( + name: "OrganizationIntegration"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index dfd5d4a983..4b3f8934f3 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -217,6 +217,68 @@ namespace Bit.MySqlMigrations.Migrations b.ToTable("Organization", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.Property("Id") @@ -407,10 +469,6 @@ namespace Bit.MySqlMigrations.Migrations b.Property("AuthenticationDate") .HasColumnType("datetime(6)"); - b.Property("RequestCountryName") - .HasMaxLength(200) - .HasColumnType("varchar(200)"); - b.Property("CreationDate") .HasColumnType("datetime(6)"); @@ -426,6 +484,10 @@ namespace Bit.MySqlMigrations.Migrations b.Property("PublicKey") .HasColumnType("longtext"); + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + b.Property("RequestDeviceIdentifier") .HasMaxLength(50) .HasColumnType("varchar(50)"); @@ -2259,6 +2321,28 @@ namespace Bit.MySqlMigrations.Migrations b.HasDiscriminator().HasValue("user_service_account"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.Designer.cs b/util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.Designer.cs new file mode 100644 index 0000000000..3639a6449e --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.Designer.cs @@ -0,0 +1,3107 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250325231701_OrganizationIntegrations")] + partial class OrganizationIntegrations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.cs b/util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.cs new file mode 100644 index 0000000000..72b8572abf --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250325231701_OrganizationIntegrations.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class OrganizationIntegrations : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrganizationIntegration", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "integer", nullable: false), + Configuration = table.Column(type: "text", nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: false), + RevisionDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationIntegration", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationIntegration_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OrganizationIntegrationConfiguration", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationIntegrationId = table.Column(type: "uuid", nullable: false), + EventType = table.Column(type: "integer", nullable: false), + Configuration = table.Column(type: "text", nullable: true), + Template = table.Column(type: "text", nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: false), + RevisionDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationIntegrationConfiguration", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationIntegrationConfiguration_OrganizationIntegratio~", + column: x => x.OrganizationIntegrationId, + principalTable: "OrganizationIntegration", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegration_OrganizationId", + table: "OrganizationIntegration", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegration_OrganizationId_Type", + table: "OrganizationIntegration", + columns: new[] { "OrganizationId", "Type" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegrationConfiguration_OrganizationIntegratio~", + table: "OrganizationIntegrationConfiguration", + column: "OrganizationIntegrationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrganizationIntegrationConfiguration"); + + migrationBuilder.DropTable( + name: "OrganizationIntegration"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index a54bc6bddf..ebb8fa470f 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -220,6 +220,68 @@ namespace Bit.PostgresMigrations.Migrations b.ToTable("Organization", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.Property("Id") @@ -410,10 +472,6 @@ namespace Bit.PostgresMigrations.Migrations b.Property("AuthenticationDate") .HasColumnType("timestamp with time zone"); - b.Property("RequestCountryName") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - b.Property("CreationDate") .HasColumnType("timestamp with time zone"); @@ -429,6 +487,10 @@ namespace Bit.PostgresMigrations.Migrations b.Property("PublicKey") .HasColumnType("text"); + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("RequestDeviceIdentifier") .HasMaxLength(50) .HasColumnType("character varying(50)"); @@ -2265,6 +2327,28 @@ namespace Bit.PostgresMigrations.Migrations b.HasDiscriminator().HasValue("user_service_account"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") diff --git a/util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.Designer.cs b/util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.Designer.cs new file mode 100644 index 0000000000..279dd87331 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.Designer.cs @@ -0,0 +1,3090 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250325231714_OrganizationIntegrations")] + partial class OrganizationIntegrations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.cs b/util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.cs new file mode 100644 index 0000000000..53550ea3e2 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250325231714_OrganizationIntegrations.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class OrganizationIntegrations : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OrganizationIntegration", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrganizationId = table.Column(type: "TEXT", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Configuration = table.Column(type: "TEXT", nullable: true), + CreationDate = table.Column(type: "TEXT", nullable: false), + RevisionDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationIntegration", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationIntegration_Organization_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organization", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OrganizationIntegrationConfiguration", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OrganizationIntegrationId = table.Column(type: "TEXT", nullable: false), + EventType = table.Column(type: "INTEGER", nullable: false), + Configuration = table.Column(type: "TEXT", nullable: true), + Template = table.Column(type: "TEXT", nullable: true), + CreationDate = table.Column(type: "TEXT", nullable: false), + RevisionDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationIntegrationConfiguration", x => x.Id); + table.ForeignKey( + name: "FK_OrganizationIntegrationConfiguration_OrganizationIntegration_OrganizationIntegrationId", + column: x => x.OrganizationIntegrationId, + principalTable: "OrganizationIntegration", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegration_OrganizationId", + table: "OrganizationIntegration", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegration_OrganizationId_Type", + table: "OrganizationIntegration", + columns: new[] { "OrganizationId", "Type" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationIntegrationConfiguration_OrganizationIntegrationId", + table: "OrganizationIntegrationConfiguration", + column: "OrganizationIntegrationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrganizationIntegrationConfiguration"); + + migrationBuilder.DropTable( + name: "OrganizationIntegration"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 824f2ffec5..753c049651 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -212,6 +212,68 @@ namespace Bit.SqliteMigrations.Migrations b.ToTable("Organization", (string)null); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.Property("Id") @@ -402,10 +464,6 @@ namespace Bit.SqliteMigrations.Migrations b.Property("AuthenticationDate") .HasColumnType("TEXT"); - b.Property("RequestCountryName") - .HasMaxLength(200) - .HasColumnType("TEXT"); - b.Property("CreationDate") .HasColumnType("TEXT"); @@ -421,6 +479,10 @@ namespace Bit.SqliteMigrations.Migrations b.Property("PublicKey") .HasColumnType("TEXT"); + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + b.Property("RequestDeviceIdentifier") .HasMaxLength(50) .HasColumnType("TEXT"); @@ -2248,6 +2310,28 @@ namespace Bit.SqliteMigrations.Migrations b.HasDiscriminator().HasValue("user_service_account"); }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => { b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") From 6178bb2db183614edfd300acb0da0c5f8171e40e Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:08:19 -0500 Subject: [PATCH 34/43] only create security tasks when a task doesn't exist for the submitted cipher (#5558) --- .../Controllers/SecurityTaskController.cs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index 2693d60825..2fe1025ba7 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -5,6 +5,7 @@ using Bit.Core; using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Queries; using Microsoft.AspNetCore.Authorization; @@ -89,11 +90,28 @@ public class SecurityTaskController : Controller public async Task> BulkCreateTasks(Guid orgId, [FromBody] BulkCreateSecurityTasksRequestModel model) { - var securityTasks = await _createManyTasksCommand.CreateAsync(orgId, model.Tasks); + // Retrieve existing pending security tasks for the organization + var pendingSecurityTasks = await _getTasksForOrganizationQuery.GetTasksAsync(orgId, SecurityTaskStatus.Pending); - await _createManyTaskNotificationsCommand.CreateAsync(orgId, securityTasks); + // Get the security tasks that are already associated with a cipher within the submitted model + var existingTasks = pendingSecurityTasks.Where(x => model.Tasks.Any(y => y.CipherId == x.CipherId)).ToList(); - var response = securityTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); + // Get tasks that need to be created + var tasksToCreateFromModel = model.Tasks.Where(x => !existingTasks.Any(y => y.CipherId == x.CipherId)).ToList(); + + ICollection newSecurityTasks = new List(); + + if (tasksToCreateFromModel.Count != 0) + { + newSecurityTasks = await _createManyTasksCommand.CreateAsync(orgId, tasksToCreateFromModel); + } + + // Combine existing tasks and newly created tasks + var allTasks = existingTasks.Concat(newSecurityTasks); + + await _createManyTaskNotificationsCommand.CreateAsync(orgId, allTasks); + + var response = allTasks.Select(x => new SecurityTasksResponseModel(x)).ToList(); return new ListResponseModel(response); } } From 54f4ba945ef9194e9131bdf2667de78e544d14b7 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 28 Mar 2025 00:13:56 +1000 Subject: [PATCH 35/43] [PM-17558] Remove ShortcutDuplicatePatchRequests feature flag (#5551) * Delete old command and feature flag switch * Rename vNext command * Remove feature flag --- .../Scim/Controllers/v2/GroupsController.cs | 28 +- .../Groups/Interfaces/IPatchGroupCommand.cs | 2 +- .../Interfaces/IPatchGroupCommandvNext.cs | 9 - .../src/Scim/Groups/PatchGroupCommand.cs | 177 ++++---- .../src/Scim/Groups/PatchGroupCommandvNext.cs | 170 -------- .../ScimServiceCollectionExtensions.cs | 1 - .../v2/GroupsControllerPatchTests.cs | 1 + .../v2/GroupsControllerPatchTestsvNext.cs | 251 ------------ .../Groups/PatchGroupCommandTests.cs | 356 +++++++++++----- .../Groups/PatchGroupCommandvNextTests.cs | 381 ------------------ src/Core/Constants.cs | 1 - 11 files changed, 368 insertions(+), 1009 deletions(-) delete mode 100644 bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommandvNext.cs delete mode 100644 bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs delete mode 100644 bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTestsvNext.cs delete mode 100644 bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandvNextTests.cs diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index 8c21793a9d..6da4001753 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -1,10 +1,8 @@ -using Bit.Core; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Groups.Interfaces; using Bit.Scim.Models; using Bit.Scim.Utilities; @@ -24,10 +22,8 @@ public class GroupsController : Controller private readonly IGetGroupsListQuery _getGroupsListQuery; private readonly IDeleteGroupCommand _deleteGroupCommand; private readonly IPatchGroupCommand _patchGroupCommand; - private readonly IPatchGroupCommandvNext _patchGroupCommandvNext; private readonly IPostGroupCommand _postGroupCommand; private readonly IPutGroupCommand _putGroupCommand; - private readonly IFeatureService _featureService; public GroupsController( IGroupRepository groupRepository, @@ -35,10 +31,8 @@ public class GroupsController : Controller IGetGroupsListQuery getGroupsListQuery, IDeleteGroupCommand deleteGroupCommand, IPatchGroupCommand patchGroupCommand, - IPatchGroupCommandvNext patchGroupCommandvNext, IPostGroupCommand postGroupCommand, - IPutGroupCommand putGroupCommand, - IFeatureService featureService + IPutGroupCommand putGroupCommand ) { _groupRepository = groupRepository; @@ -46,10 +40,8 @@ public class GroupsController : Controller _getGroupsListQuery = getGroupsListQuery; _deleteGroupCommand = deleteGroupCommand; _patchGroupCommand = patchGroupCommand; - _patchGroupCommandvNext = patchGroupCommandvNext; _postGroupCommand = postGroupCommand; _putGroupCommand = putGroupCommand; - _featureService = featureService; } [HttpGet("{id}")] @@ -103,21 +95,13 @@ public class GroupsController : Controller [HttpPatch("{id}")] public async Task Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model) { - if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests)) + var group = await _groupRepository.GetByIdAsync(id); + if (group == null || group.OrganizationId != organizationId) { - var group = await _groupRepository.GetByIdAsync(id); - if (group == null || group.OrganizationId != organizationId) - { - throw new NotFoundException("Group not found."); - } - - await _patchGroupCommandvNext.PatchGroupAsync(group, model); - return new NoContentResult(); + throw new NotFoundException("Group not found."); } - var organization = await _organizationRepository.GetByIdAsync(organizationId); - await _patchGroupCommand.PatchGroupAsync(organization, id, model); - + await _patchGroupCommand.PatchGroupAsync(group, model); return new NoContentResult(); } diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs index b9516cf706..2856eaa860 100644 --- a/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommand.cs @@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces; public interface IPatchGroupCommand { - Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model); + Task PatchGroupAsync(Group group, ScimPatchModel model); } diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommandvNext.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommandvNext.cs deleted file mode 100644 index f51cc54079..0000000000 --- a/bitwarden_license/src/Scim/Groups/Interfaces/IPatchGroupCommandvNext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Scim.Models; - -namespace Bit.Scim.Groups.Interfaces; - -public interface IPatchGroupCommandvNext -{ - Task PatchGroupAsync(Group group, ScimPatchModel model); -} diff --git a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs index 94d9b7a4c2..ab082fc2a6 100644 --- a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs @@ -5,8 +5,10 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Scim.Groups.Interfaces; using Bit.Scim.Models; +using Bit.Scim.Utilities; namespace Bit.Scim.Groups; @@ -16,118 +18,137 @@ public class PatchGroupCommand : IPatchGroupCommand private readonly IGroupService _groupService; private readonly IUpdateGroupCommand _updateGroupCommand; private readonly ILogger _logger; + private readonly IOrganizationRepository _organizationRepository; public PatchGroupCommand( IGroupRepository groupRepository, IGroupService groupService, IUpdateGroupCommand updateGroupCommand, - ILogger logger) + ILogger logger, + IOrganizationRepository organizationRepository) { _groupRepository = groupRepository; _groupService = groupService; _updateGroupCommand = updateGroupCommand; _logger = logger; + _organizationRepository = organizationRepository; } - public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model) + public async Task PatchGroupAsync(Group group, ScimPatchModel model) { - var group = await _groupRepository.GetByIdAsync(id); - if (group == null || group.OrganizationId != organization.Id) - { - throw new NotFoundException("Group not found."); - } - - var operationHandled = false; foreach (var operation in model.Operations) { - // Replace operations - if (operation.Op?.ToLowerInvariant() == "replace") - { - // Replace a list of members - if (operation.Path?.ToLowerInvariant() == "members") + await HandleOperationAsync(group, operation); + } + } + + private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation) + { + switch (operation.Op?.ToLowerInvariant()) + { + // Replace a list of members + case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members: { var ids = GetOperationValueIds(operation.Value); await _groupRepository.UpdateUsersAsync(group.Id, ids); - operationHandled = true; + break; } - // Replace group name from path - else if (operation.Path?.ToLowerInvariant() == "displayname") + + // Replace group name from path + case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName: { group.Name = operation.Value.GetString(); + var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - operationHandled = true; + break; } - // Replace group name from value object - else if (string.IsNullOrWhiteSpace(operation.Path) && - operation.Value.TryGetProperty("displayName", out var displayNameProperty)) + + // Replace group name from value object + case PatchOps.Replace when + string.IsNullOrWhiteSpace(operation.Path) && + operation.Value.TryGetProperty("displayName", out var displayNameProperty): { group.Name = displayNameProperty.GetString(); + var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - operationHandled = true; + break; } - } + // Add a single member - else if (operation.Op?.ToLowerInvariant() == "add" && + case PatchOps.Add when !string.IsNullOrWhiteSpace(operation.Path) && - operation.Path.ToLowerInvariant().StartsWith("members[value eq ")) - { - var addId = GetOperationPathId(operation.Path); - if (addId.HasValue) + operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) && + TryGetOperationPathId(operation.Path, out var addId): + { + await AddMembersAsync(group, [addId]); + break; + } + + // Add a list of members + case PatchOps.Add when + operation.Path?.ToLowerInvariant() == PatchPaths.Members: + { + await AddMembersAsync(group, GetOperationValueIds(operation.Value)); + break; + } + + // Remove a single member + case PatchOps.Remove when + !string.IsNullOrWhiteSpace(operation.Path) && + operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) && + TryGetOperationPathId(operation.Path, out var removeId): + { + await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM); + break; + } + + // Remove a list of members + case PatchOps.Remove when + operation.Path?.ToLowerInvariant() == PatchPaths.Members: { var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); - orgUserIds.Add(addId.Value); + foreach (var v in GetOperationValueIds(operation.Value)) + { + orgUserIds.Remove(v); + } await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); - operationHandled = true; + break; } - } - // Add a list of members - else if (operation.Op?.ToLowerInvariant() == "add" && - operation.Path?.ToLowerInvariant() == "members") - { - var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); - foreach (var v in GetOperationValueIds(operation.Value)) - { - orgUserIds.Add(v); - } - await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); - operationHandled = true; - } - // Remove a single member - else if (operation.Op?.ToLowerInvariant() == "remove" && - !string.IsNullOrWhiteSpace(operation.Path) && - operation.Path.ToLowerInvariant().StartsWith("members[value eq ")) - { - var removeId = GetOperationPathId(operation.Path); - if (removeId.HasValue) - { - await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM); - operationHandled = true; - } - } - // Remove a list of members - else if (operation.Op?.ToLowerInvariant() == "remove" && - operation.Path?.ToLowerInvariant() == "members") - { - var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); - foreach (var v in GetOperationValueIds(operation.Value)) - { - orgUserIds.Remove(v); - } - await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); - operationHandled = true; - } - } - if (!operationHandled) - { - _logger.LogWarning("Group patch operation not handled: {0} : ", - string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}"))); + default: + { + _logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path); + break; + } } } - private List GetOperationValueIds(JsonElement objArray) + private async Task AddMembersAsync(Group group, HashSet usersToAdd) { - var ids = new List(); + // Azure Entra ID is known to send redundant "add" requests for each existing member every time any member + // is removed. To avoid excessive load on the database, we check against the high availability replica and + // return early if they already exist. + var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true); + if (usersToAdd.IsSubsetOf(groupMembers)) + { + _logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id); + return; + } + + await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd); + } + + private static HashSet GetOperationValueIds(JsonElement objArray) + { + var ids = new HashSet(); foreach (var obj in objArray.EnumerateArray()) { if (obj.TryGetProperty("value", out var valueProperty)) @@ -141,13 +162,9 @@ public class PatchGroupCommand : IPatchGroupCommand return ids; } - private Guid? GetOperationPathId(string path) + private static bool TryGetOperationPathId(string path, out Guid pathId) { // Parse Guid from string like: members[value eq "{GUID}"}] - if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id)) - { - return id; - } - return null; + return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId); } } diff --git a/bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs b/bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs deleted file mode 100644 index 359df4bc94..0000000000 --- a/bitwarden_license/src/Scim/Groups/PatchGroupCommandvNext.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Scim.Groups.Interfaces; -using Bit.Scim.Models; -using Bit.Scim.Utilities; - -namespace Bit.Scim.Groups; - -public class PatchGroupCommandvNext : IPatchGroupCommandvNext -{ - private readonly IGroupRepository _groupRepository; - private readonly IGroupService _groupService; - private readonly IUpdateGroupCommand _updateGroupCommand; - private readonly ILogger _logger; - private readonly IOrganizationRepository _organizationRepository; - - public PatchGroupCommandvNext( - IGroupRepository groupRepository, - IGroupService groupService, - IUpdateGroupCommand updateGroupCommand, - ILogger logger, - IOrganizationRepository organizationRepository) - { - _groupRepository = groupRepository; - _groupService = groupService; - _updateGroupCommand = updateGroupCommand; - _logger = logger; - _organizationRepository = organizationRepository; - } - - public async Task PatchGroupAsync(Group group, ScimPatchModel model) - { - foreach (var operation in model.Operations) - { - await HandleOperationAsync(group, operation); - } - } - - private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation) - { - switch (operation.Op?.ToLowerInvariant()) - { - // Replace a list of members - case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members: - { - var ids = GetOperationValueIds(operation.Value); - await _groupRepository.UpdateUsersAsync(group.Id, ids); - break; - } - - // Replace group name from path - case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName: - { - group.Name = operation.Value.GetString(); - var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId); - if (organization == null) - { - throw new NotFoundException(); - } - await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - break; - } - - // Replace group name from value object - case PatchOps.Replace when - string.IsNullOrWhiteSpace(operation.Path) && - operation.Value.TryGetProperty("displayName", out var displayNameProperty): - { - group.Name = displayNameProperty.GetString(); - var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId); - if (organization == null) - { - throw new NotFoundException(); - } - await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - break; - } - - // Add a single member - case PatchOps.Add when - !string.IsNullOrWhiteSpace(operation.Path) && - operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) && - TryGetOperationPathId(operation.Path, out var addId): - { - await AddMembersAsync(group, [addId]); - break; - } - - // Add a list of members - case PatchOps.Add when - operation.Path?.ToLowerInvariant() == PatchPaths.Members: - { - await AddMembersAsync(group, GetOperationValueIds(operation.Value)); - break; - } - - // Remove a single member - case PatchOps.Remove when - !string.IsNullOrWhiteSpace(operation.Path) && - operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) && - TryGetOperationPathId(operation.Path, out var removeId): - { - await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM); - break; - } - - // Remove a list of members - case PatchOps.Remove when - operation.Path?.ToLowerInvariant() == PatchPaths.Members: - { - var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet(); - foreach (var v in GetOperationValueIds(operation.Value)) - { - orgUserIds.Remove(v); - } - await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds); - break; - } - - default: - { - _logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path); - break; - } - } - } - - private async Task AddMembersAsync(Group group, HashSet usersToAdd) - { - // Azure Entra ID is known to send redundant "add" requests for each existing member every time any member - // is removed. To avoid excessive load on the database, we check against the high availability replica and - // return early if they already exist. - var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true); - if (usersToAdd.IsSubsetOf(groupMembers)) - { - _logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id); - return; - } - - await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd); - } - - private static HashSet GetOperationValueIds(JsonElement objArray) - { - var ids = new HashSet(); - foreach (var obj in objArray.EnumerateArray()) - { - if (obj.TryGetProperty("value", out var valueProperty)) - { - if (valueProperty.TryGetGuid(out var guid)) - { - ids.Add(guid); - } - } - } - return ids; - } - - private static bool TryGetOperationPathId(string path, out Guid pathId) - { - // Parse Guid from string like: members[value eq "{GUID}"}] - return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId); - } -} diff --git a/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs b/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs index b5d866524a..75b60a71fc 100644 --- a/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Scim/Utilities/ScimServiceCollectionExtensions.cs @@ -10,7 +10,6 @@ public static class ScimServiceCollectionExtensions public static void AddScimGroupCommands(this IServiceCollection services) { services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTests.cs index eaa5b3dcd7..66ce386d07 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTests.cs @@ -20,6 +20,7 @@ public class GroupsControllerPatchTests : IClassFixture, { var databaseContext = _factory.GetDatabaseContext(); _factory.ReinitializeDbForTests(databaseContext); + return Task.CompletedTask; } diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTestsvNext.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTestsvNext.cs deleted file mode 100644 index f66184a8a2..0000000000 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerPatchTestsvNext.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.Text.Json; -using Bit.Core; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Services; -using Bit.Scim.Groups.Interfaces; -using Bit.Scim.IntegrationTest.Factories; -using Bit.Scim.Models; -using Bit.Scim.Utilities; -using Bit.Test.Common.Helpers; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Scim.IntegrationTest.Controllers.v2; - -public class GroupsControllerPatchTestsvNext : IClassFixture, IAsyncLifetime -{ - private readonly ScimApplicationFactory _factory; - - public GroupsControllerPatchTestsvNext(ScimApplicationFactory factory) - { - _factory = factory; - - // Enable the feature flag for new PatchGroupsCommand and stub out the old command to be safe - _factory.SubstituteService((IFeatureService featureService) - => featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests).Returns(true)); - _factory.SubstituteService((IPatchGroupCommand patchGroupCommand) - => patchGroupCommand.PatchGroupAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .ThrowsAsync(new Exception("This test suite should be testing the vNext command, but the existing command was called."))); - } - - public Task InitializeAsync() - { - var databaseContext = _factory.GetDatabaseContext(); - _factory.ReinitializeDbForTests(databaseContext); - - return Task.CompletedTask; - } - - Task IAsyncLifetime.DisposeAsync() => Task.CompletedTask; - - [Fact] - public async Task Patch_ReplaceDisplayName_Success() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = ScimApplicationFactory.TestGroupId1; - var newDisplayName = "Patch Display Name"; - var inputModel = new ScimPatchModel - { - Operations = new List() - { - new ScimPatchModel.OperationModel - { - Op = "replace", - Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement - } - }, - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); - - var databaseContext = _factory.GetDatabaseContext(); - var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId); - Assert.Equal(newDisplayName, group.Name); - - Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount, databaseContext.GroupUsers.Count()); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1)); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4)); - } - - [Fact] - public async Task Patch_ReplaceMembers_Success() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = ScimApplicationFactory.TestGroupId1; - var inputModel = new ScimPatchModel - { - Operations = new List() - { - new ScimPatchModel.OperationModel - { - Op = "replace", - Path = "members", - Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}}]").RootElement - } - }, - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); - - var databaseContext = _factory.GetDatabaseContext(); - Assert.Single(databaseContext.GroupUsers); - - Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count()); - var groupUser = databaseContext.GroupUsers.FirstOrDefault(); - Assert.Equal(ScimApplicationFactory.TestOrganizationUserId2, groupUser.OrganizationUserId); - } - - [Fact] - public async Task Patch_AddSingleMember_Success() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = ScimApplicationFactory.TestGroupId1; - var inputModel = new ScimPatchModel - { - Operations = new List() - { - new ScimPatchModel.OperationModel - { - Op = "add", - Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId2}\"]", - Value = JsonDocument.Parse("{}").RootElement - } - }, - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); - - var databaseContext = _factory.GetDatabaseContext(); - Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount + 1, databaseContext.GroupUsers.Count()); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId1)); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2)); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId4)); - } - - [Fact] - public async Task Patch_AddListMembers_Success() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = ScimApplicationFactory.TestGroupId2; - var inputModel = new ScimPatchModel - { - Operations = new List() - { - new ScimPatchModel.OperationModel - { - Op = "add", - Path = "members", - Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId2}\"}},{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId3}\"}}]").RootElement - } - }, - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); - - var databaseContext = _factory.GetDatabaseContext(); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId2)); - Assert.True(databaseContext.GroupUsers.Any(gu => gu.GroupId == groupId && gu.OrganizationUserId == ScimApplicationFactory.TestOrganizationUserId3)); - } - - [Fact] - public async Task Patch_RemoveSingleMember_ReplaceDisplayName_Success() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = ScimApplicationFactory.TestGroupId1; - var newDisplayName = "Patch Display Name"; - var inputModel = new ScimPatchModel - { - Operations = new List() - { - new ScimPatchModel.OperationModel - { - Op = "remove", - Path = $"members[value eq \"{ScimApplicationFactory.TestOrganizationUserId1}\"]", - Value = JsonDocument.Parse("{}").RootElement - }, - new ScimPatchModel.OperationModel - { - Op = "replace", - Value = JsonDocument.Parse($"{{\"displayName\":\"{newDisplayName}\"}}").RootElement - } - }, - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); - - var databaseContext = _factory.GetDatabaseContext(); - Assert.Equal(ScimApplicationFactory.InitialGroupUsersCount - 1, databaseContext.GroupUsers.Count()); - Assert.Equal(ScimApplicationFactory.InitialGroupCount, databaseContext.Groups.Count()); - - var group = databaseContext.Groups.FirstOrDefault(g => g.Id == groupId); - Assert.Equal(newDisplayName, group.Name); - } - - [Fact] - public async Task Patch_RemoveListMembers_Success() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = ScimApplicationFactory.TestGroupId1; - var inputModel = new ScimPatchModel - { - Operations = new List() - { - new ScimPatchModel.OperationModel - { - Op = "remove", - Path = "members", - Value = JsonDocument.Parse($"[{{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId1}\"}}, {{\"value\":\"{ScimApplicationFactory.TestOrganizationUserId4}\"}}]").RootElement - } - }, - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); - - var databaseContext = _factory.GetDatabaseContext(); - Assert.Empty(databaseContext.GroupUsers); - } - - [Fact] - public async Task Patch_NotFound() - { - var organizationId = ScimApplicationFactory.TestOrganizationId1; - var groupId = Guid.NewGuid(); - var inputModel = new Models.ScimPatchModel - { - Operations = new List(), - Schemas = new List() { ScimConstants.Scim2SchemaGroup } - }; - var expectedResponse = new ScimErrorResponseModel - { - Status = StatusCodes.Status404NotFound, - Detail = "Group not found.", - Schemas = new List { ScimConstants.Scim2SchemaError } - }; - - var context = await _factory.GroupsPatchAsync(organizationId, groupId, inputModel); - - Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); - - var responseModel = JsonSerializer.Deserialize(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); - } -} diff --git a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs index ff8cb3b546..1b02e62970 100644 --- a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandTests.cs @@ -1,15 +1,18 @@ using System.Text.Json; +using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Scim.Groups; using Bit.Scim.Models; using Bit.Scim.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -20,19 +23,16 @@ public class PatchGroupCommandTests { [Theory] [BitAutoData] - public async Task PatchGroup_ReplaceListMembers_Success(SutProvider sutProvider, Organization organization, Group group, IEnumerable userIds) + public async Task PatchGroup_ReplaceListMembers_Success(SutProvider sutProvider, + Organization organization, Group group, IEnumerable userIds) { group.OrganizationId = organization.Id; - sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); - - var scimPatchModel = new Models.ScimPatchModel + var scimPatchModel = new ScimPatchModel { Operations = new List { - new ScimPatchModel.OperationModel + new() { Op = "replace", Path = "members", @@ -42,26 +42,31 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => userIds.Contains(id)))); + await sutProvider.GetDependency().Received(1).UpdateUsersAsync( + group.Id, + Arg.Is>(arg => + arg.Count() == userIds.Count() && + arg.ToHashSet().SetEquals(userIds))); } [Theory] [BitAutoData] - public async Task PatchGroup_ReplaceDisplayNameFromPath_Success(SutProvider sutProvider, Organization organization, Group group, string displayName) + public async Task PatchGroup_ReplaceDisplayNameFromPath_Success( + SutProvider sutProvider, Organization organization, Group group, string displayName) { group.OrganizationId = organization.Id; - sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); - var scimPatchModel = new Models.ScimPatchModel + var scimPatchModel = new ScimPatchModel { Operations = new List { - new ScimPatchModel.OperationModel + new() { Op = "replace", Path = "displayname", @@ -71,27 +76,55 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); await sutProvider.GetDependency().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); Assert.Equal(displayName, group.Name); } + [Theory] + [BitAutoData] + public async Task PatchGroup_ReplaceDisplayNameFromPath_MissingOrganization_Throws( + SutProvider sutProvider, Organization organization, Group group, string displayName) + { + group.OrganizationId = organization.Id; + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns((Organization)null); + + var scimPatchModel = new ScimPatchModel + { + Operations = new List + { + new() + { + Op = "replace", + Path = "displayname", + Value = JsonDocument.Parse($"\"{displayName}\"").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel)); + } + [Theory] [BitAutoData] public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider sutProvider, Organization organization, Group group, string displayName) { group.OrganizationId = organization.Id; - sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); - var scimPatchModel = new Models.ScimPatchModel + var scimPatchModel = new ScimPatchModel { Operations = new List { - new ScimPatchModel.OperationModel + new() { Op = "replace", Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement @@ -100,12 +133,39 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); await sutProvider.GetDependency().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); Assert.Equal(displayName, group.Name); } + [Theory] + [BitAutoData] + public async Task PatchGroup_ReplaceDisplayNameFromValueObject_MissingOrganization_Throws( + SutProvider sutProvider, Organization organization, Group group, string displayName) + { + group.OrganizationId = organization.Id; + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns((Organization)null); + + var scimPatchModel = new ScimPatchModel + { + Operations = new List + { + new() + { + Op = "replace", + Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await Assert.ThrowsAsync(() => sutProvider.Sut.PatchGroupAsync(group, scimPatchModel)); + } + [Theory] [BitAutoData] public async Task PatchGroup_AddSingleMember_Success(SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers, Guid userId) @@ -113,18 +173,14 @@ public class PatchGroupCommandTests group.OrganizationId = organization.Id; sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id) + .GetManyUserIdsByIdAsync(group.Id, true) .Returns(existingMembers); - var scimPatchModel = new Models.ScimPatchModel + var scimPatchModel = new ScimPatchModel { Operations = new List { - new ScimPatchModel.OperationModel + new() { Op = "add", Path = $"members[value eq \"{userId}\"]", @@ -133,9 +189,47 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => existingMembers.Append(userId).Contains(id)))); + await sutProvider.GetDependency().Received(1).AddGroupUsersByIdAsync( + group.Id, + Arg.Is>(arg => arg.Single() == userId)); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup( + SutProvider sutProvider, + Organization organization, + Group group, + ICollection existingMembers) + { + // User being added is already in group + var userId = existingMembers.First(); + group.OrganizationId = organization.Id; + + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(group.Id, true) + .Returns(existingMembers); + + var scimPatchModel = new ScimPatchModel + { + Operations = new List + { + new() + { + Op = "add", + Path = $"members[value eq \"{userId}\"]", + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AddGroupUsersByIdAsync(default, default); } [Theory] @@ -145,18 +239,14 @@ public class PatchGroupCommandTests group.OrganizationId = organization.Id; sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id) + .GetManyUserIdsByIdAsync(group.Id, true) .Returns(existingMembers); - var scimPatchModel = new Models.ScimPatchModel + var scimPatchModel = new ScimPatchModel { Operations = new List { - new ScimPatchModel.OperationModel + new() { Op = "add", Path = $"members", @@ -166,9 +256,101 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => existingMembers.Concat(userIds).Contains(id)))); + await sutProvider.GetDependency().Received(1).AddGroupUsersByIdAsync( + group.Id, + Arg.Is>(arg => + arg.Count() == userIds.Count && + arg.ToHashSet().SetEquals(userIds))); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest( + SutProvider sutProvider, Organization organization, Group group, + ICollection existingMembers) + { + // Create 3 userIds + var fixture = new Fixture { RepeatCount = 3 }; + var userIds = fixture.CreateMany().ToList(); + + // Copy the list and add a duplicate + var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList(); + Assert.Equal(4, userIdsWithDuplicate.Count); + + group.OrganizationId = organization.Id; + + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(group.Id, true) + .Returns(existingMembers); + + var scimPatchModel = new ScimPatchModel + { + Operations = new List + { + new() + { + Op = "add", + Path = $"members", + Value = JsonDocument.Parse(JsonSerializer + .Serialize(userIdsWithDuplicate + .Select(uid => new { value = uid }) + .ToArray())).RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); + + await sutProvider.GetDependency().Received(1).AddGroupUsersByIdAsync( + group.Id, + Arg.Is>(arg => + arg.Count() == 3 && + arg.ToHashSet().SetEquals(userIds))); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup( + SutProvider sutProvider, + Organization organization, Group group, + ICollection existingMembers, + ICollection userIds) + { + // A user is already in the group, but some still need to be added + userIds.Add(existingMembers.First()); + + group.OrganizationId = organization.Id; + + sutProvider.GetDependency() + .GetManyUserIdsByIdAsync(group.Id, true) + .Returns(existingMembers); + + var scimPatchModel = new ScimPatchModel + { + Operations = new List + { + new() + { + Op = "add", + Path = $"members", + Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement + } + }, + Schemas = new List { ScimConstants.Scim2SchemaUser } + }; + + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); + + await sutProvider.GetDependency() + .Received(1) + .AddGroupUsersByIdAsync( + group.Id, + Arg.Is>(arg => + arg.Count() == userIds.Count && + arg.ToHashSet().SetEquals(userIds))); } [Theory] @@ -177,10 +359,6 @@ public class PatchGroupCommandTests { group.OrganizationId = organization.Id; - sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); - var scimPatchModel = new Models.ScimPatchModel { Operations = new List @@ -194,21 +372,19 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); await sutProvider.GetDependency().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM); } [Theory] [BitAutoData] - public async Task PatchGroup_RemoveListMembers_Success(SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers) + public async Task PatchGroup_RemoveListMembers_Success(SutProvider sutProvider, + Organization organization, Group group, ICollection existingMembers) { + List usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()]; group.OrganizationId = organization.Id; - sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); - sutProvider.GetDependency() .GetManyUserIdsByIdAsync(group.Id) .Returns(existingMembers); @@ -217,30 +393,58 @@ public class PatchGroupCommandTests { Operations = new List { - new ScimPatchModel.OperationModel + new() { Op = "remove", Path = $"members", - Value = JsonDocument.Parse(JsonSerializer.Serialize(existingMembers.Select(uid => new { value = uid }).ToArray())).RootElement + Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement } }, Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - await sutProvider.GetDependency().Received(1).UpdateUsersAsync(group.Id, Arg.Is>(arg => arg.All(id => existingMembers.Contains(id)))); + var expectedRemainingUsers = existingMembers.Skip(2).ToList(); + await sutProvider.GetDependency() + .Received(1) + .UpdateUsersAsync( + group.Id, + Arg.Is>(arg => + arg.Count() == expectedRemainingUsers.Count && + arg.ToHashSet().SetEquals(expectedRemainingUsers))); } [Theory] [BitAutoData] - public async Task PatchGroup_NoAction_Success(SutProvider sutProvider, Organization organization, Group group) + public async Task PatchGroup_InvalidOperation_Success(SutProvider sutProvider, Organization organization, Group group) { group.OrganizationId = organization.Id; - sutProvider.GetDependency() - .GetByIdAsync(group.Id) - .Returns(group); + var scimPatchModel = new Models.ScimPatchModel + { + Operations = [new ScimPatchModel.OperationModel { Op = "invalid operation" }], + Schemas = [ScimConstants.Scim2SchemaUser] + }; + + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); + + // Assert: no operation performed + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); + + // Assert: logging + sutProvider.GetDependency>().ReceivedWithAnyArgs().LogWarning(default); + } + + [Theory] + [BitAutoData] + public async Task PatchGroup_NoOperation_Success( + SutProvider sutProvider, Organization organization, Group group) + { + group.OrganizationId = organization.Id; var scimPatchModel = new Models.ScimPatchModel { @@ -248,45 +452,11 @@ public class PatchGroupCommandTests Schemas = new List { ScimConstants.Scim2SchemaUser } }; - await sutProvider.Sut.PatchGroupAsync(organization, group.Id, scimPatchModel); + await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); } - - [Theory] - [BitAutoData] - public async Task PatchGroup_NotFound_Throws(SutProvider sutProvider, Organization organization, Guid groupId) - { - var scimPatchModel = new Models.ScimPatchModel - { - Operations = new List(), - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel)); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_MismatchingOrganizationId_Throws(SutProvider sutProvider, Organization organization, Guid groupId) - { - var scimPatchModel = new Models.ScimPatchModel - { - Operations = new List(), - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - sutProvider.GetDependency() - .GetByIdAsync(groupId) - .Returns(new Group - { - Id = groupId, - OrganizationId = Guid.NewGuid() - }); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.PatchGroupAsync(organization, groupId, scimPatchModel)); - } } diff --git a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandvNextTests.cs b/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandvNextTests.cs deleted file mode 100644 index b9877f0b71..0000000000 --- a/bitwarden_license/test/Scim.Test/Groups/PatchGroupCommandvNextTests.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System.Text.Json; -using AutoFixture; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Enums; -using Bit.Core.Repositories; -using Bit.Scim.Groups; -using Bit.Scim.Models; -using Bit.Scim.Utilities; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Scim.Test.Groups; - -[SutProviderCustomize] -public class PatchGroupCommandvNextTests -{ - [Theory] - [BitAutoData] - public async Task PatchGroup_ReplaceListMembers_Success(SutProvider sutProvider, - Organization organization, Group group, IEnumerable userIds) - { - group.OrganizationId = organization.Id; - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "replace", - Path = "members", - Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).UpdateUsersAsync( - group.Id, - Arg.Is>(arg => - arg.Count() == userIds.Count() && - arg.ToHashSet().SetEquals(userIds))); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_ReplaceDisplayNameFromPath_Success( - SutProvider sutProvider, Organization organization, Group group, string displayName) - { - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "replace", - Path = "displayname", - Value = JsonDocument.Parse($"\"{displayName}\"").RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - Assert.Equal(displayName, group.Name); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_ReplaceDisplayNameFromValueObject_Success(SutProvider sutProvider, Organization organization, Group group, string displayName) - { - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organization.Id) - .Returns(organization); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "replace", - Value = JsonDocument.Parse($"{{\"displayName\":\"{displayName}\"}}").RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).UpdateGroupAsync(group, organization, EventSystemUser.SCIM); - Assert.Equal(displayName, group.Name); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_AddSingleMember_Success(SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers, Guid userId) - { - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id, true) - .Returns(existingMembers); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "add", - Path = $"members[value eq \"{userId}\"]", - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).AddGroupUsersByIdAsync( - group.Id, - Arg.Is>(arg => arg.Single() == userId)); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_AddSingleMember_ReturnsEarlyIfAlreadyInGroup( - SutProvider sutProvider, - Organization organization, - Group group, - ICollection existingMembers) - { - // User being added is already in group - var userId = existingMembers.First(); - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id, true) - .Returns(existingMembers); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "add", - Path = $"members[value eq \"{userId}\"]", - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .AddGroupUsersByIdAsync(default, default); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_AddListMembers_Success(SutProvider sutProvider, Organization organization, Group group, ICollection existingMembers, ICollection userIds) - { - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id, true) - .Returns(existingMembers); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "add", - Path = $"members", - Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).AddGroupUsersByIdAsync( - group.Id, - Arg.Is>(arg => - arg.Count() == userIds.Count && - arg.ToHashSet().SetEquals(userIds))); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_AddListMembers_IgnoresDuplicatesInRequest( - SutProvider sutProvider, Organization organization, Group group, - ICollection existingMembers) - { - // Create 3 userIds - var fixture = new Fixture { RepeatCount = 3 }; - var userIds = fixture.CreateMany().ToList(); - - // Copy the list and add a duplicate - var userIdsWithDuplicate = userIds.Append(userIds.First()).ToList(); - Assert.Equal(4, userIdsWithDuplicate.Count); - - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id, true) - .Returns(existingMembers); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "add", - Path = $"members", - Value = JsonDocument.Parse(JsonSerializer - .Serialize(userIdsWithDuplicate - .Select(uid => new { value = uid }) - .ToArray())).RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).AddGroupUsersByIdAsync( - group.Id, - Arg.Is>(arg => - arg.Count() == 3 && - arg.ToHashSet().SetEquals(userIds))); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_AddListMembers_SuccessIfOnlySomeUsersAreInGroup( - SutProvider sutProvider, - Organization organization, Group group, - ICollection existingMembers, - ICollection userIds) - { - // A user is already in the group, but some still need to be added - userIds.Add(existingMembers.First()); - - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id, true) - .Returns(existingMembers); - - var scimPatchModel = new ScimPatchModel - { - Operations = new List - { - new() - { - Op = "add", - Path = $"members", - Value = JsonDocument.Parse(JsonSerializer.Serialize(userIds.Select(uid => new { value = uid }).ToArray())).RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency() - .Received(1) - .AddGroupUsersByIdAsync( - group.Id, - Arg.Is>(arg => - arg.Count() == userIds.Count && - arg.ToHashSet().SetEquals(userIds))); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_RemoveSingleMember_Success(SutProvider sutProvider, Organization organization, Group group, Guid userId) - { - group.OrganizationId = organization.Id; - - var scimPatchModel = new Models.ScimPatchModel - { - Operations = new List - { - new ScimPatchModel.OperationModel - { - Op = "remove", - Path = $"members[value eq \"{userId}\"]", - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().Received(1).DeleteUserAsync(group, userId, EventSystemUser.SCIM); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_RemoveListMembers_Success(SutProvider sutProvider, - Organization organization, Group group, ICollection existingMembers) - { - List usersToRemove = [existingMembers.First(), existingMembers.Skip(1).First()]; - group.OrganizationId = organization.Id; - - sutProvider.GetDependency() - .GetManyUserIdsByIdAsync(group.Id) - .Returns(existingMembers); - - var scimPatchModel = new Models.ScimPatchModel - { - Operations = new List - { - new() - { - Op = "remove", - Path = $"members", - Value = JsonDocument.Parse(JsonSerializer.Serialize(usersToRemove.Select(uid => new { value = uid }).ToArray())).RootElement - } - }, - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - var expectedRemainingUsers = existingMembers.Skip(2).ToList(); - await sutProvider.GetDependency() - .Received(1) - .UpdateUsersAsync( - group.Id, - Arg.Is>(arg => - arg.Count() == expectedRemainingUsers.Count && - arg.ToHashSet().SetEquals(expectedRemainingUsers))); - } - - [Theory] - [BitAutoData] - public async Task PatchGroup_NoAction_Success( - SutProvider sutProvider, Organization organization, Group group) - { - group.OrganizationId = organization.Id; - - var scimPatchModel = new Models.ScimPatchModel - { - Operations = new List(), - Schemas = new List { ScimConstants.Scim2SchemaUser } - }; - - await sutProvider.Sut.PatchGroupAsync(group, scimPatchModel); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateUsersAsync(default, default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyUserIdsByIdAsync(default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateGroupAsync(default, default); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteUserAsync(default, default); - } -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c629881458..379915c37d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -106,7 +106,6 @@ public static class FeatureFlagKeys public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; - public const string ShortcutDuplicatePatchRequests = "pm-16812-shortcut-duplicate-patch-requests"; public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; public const string PolicyRequirements = "pm-14439-policy-requirements"; From fb0567b45ec2cbdc1c4bf43b0a2bbf3ad93cdf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Thu, 27 Mar 2025 14:49:38 +0000 Subject: [PATCH 36/43] [PM-18523] Add SSO external ID visibility feature flag (#5559) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 379915c37d..3a4d3b2e13 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -108,6 +108,7 @@ public static class FeatureFlagKeys public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string PushSyncOrgKeysOnRevokeRestore = "pm-17168-push-sync-org-keys-on-revoke-restore"; public const string PolicyRequirements = "pm-14439-policy-requirements"; + public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; /* Tools Team */ public const string ItemShare = "item-share"; From 6e81cee22160ec17ba79cdd13e0380c2f0b897ad Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Fri, 28 Mar 2025 09:20:35 -0700 Subject: [PATCH 37/43] Introduce organization integration configuration details (#5568) --- ...EventTypeOrganizationIdIntegrationType.sql | 20 ++++++++ ...EventTypeOrganizationIdIntegrationType.sql | 22 --------- ...ionIntegrationConfigurationDetailsView.sql | 13 +++++ ...izationIntegrationConfigurationDetails.sql | 49 +++++++++++++++++++ 4 files changed, 82 insertions(+), 22 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql delete mode 100644 src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql create mode 100644 src/Sql/dbo/Views/OrganizationIntegrationConfigurationDetailsView.sql create mode 100644 util/Migrator/DbScripts/2025-03-27_00_OrganizationIntegrationConfigurationDetails.sql diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql new file mode 100644 index 0000000000..3240402916 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType.sql @@ -0,0 +1,20 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType] + @EventType SMALLINT, + @OrganizationId UNIQUEIDENTIFIER, + @IntegrationType SMALLINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationDetailsView] oic + WHERE + oic.[EventType] = @EventType + AND + oic.[OrganizationId] = @OrganizationId + AND + oic.[IntegrationType] = @IntegrationType +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql deleted file mode 100644 index 113aa2e529..0000000000 --- a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType] - @EventType SMALLINT, - @OrganizationId UNIQUEIDENTIFIER, - @IntegrationType SMALLINT -AS -BEGIN - SET NOCOUNT ON - - SELECT - oic.* - FROM - [dbo].[OrganizationIntegrationConfigurationView] oic - INNER JOIN - [dbo].[OrganizationIntegration] oi ON oi.[Id] = oic.[OrganizationIntegrationId] - WHERE - oic.[EventType] = @EventType - AND - oi.[OrganizationId] = @OrganizationId - AND - oi.[Type] = @IntegrationType -END -GO diff --git a/src/Sql/dbo/Views/OrganizationIntegrationConfigurationDetailsView.sql b/src/Sql/dbo/Views/OrganizationIntegrationConfigurationDetailsView.sql new file mode 100644 index 0000000000..45609da551 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationIntegrationConfigurationDetailsView.sql @@ -0,0 +1,13 @@ +CREATE VIEW [dbo].[OrganizationIntegrationConfigurationDetailsView] +AS + SELECT + oi.[OrganizationId], + oi.[Type] AS [IntegrationType], + oic.[EventType], + oic.[Configuration], + oi.[Configuration] AS [IntegrationConfiguration], + oic.[Template] + FROM + [dbo].[OrganizationIntegrationConfiguration] oic + INNER JOIN + [dbo].[OrganizationIntegration] oi ON oi.[Id] = oic.[OrganizationIntegrationId] diff --git a/util/Migrator/DbScripts/2025-03-27_00_OrganizationIntegrationConfigurationDetails.sql b/util/Migrator/DbScripts/2025-03-27_00_OrganizationIntegrationConfigurationDetails.sql new file mode 100644 index 0000000000..233afa7e3e --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-27_00_OrganizationIntegrationConfigurationDetails.sql @@ -0,0 +1,49 @@ +IF EXISTS(SELECT * +FROM sys.views +WHERE [Name] = 'OrganizationIntegrationConfigurationDetailsView') +BEGIN + DROP VIEW [dbo].[OrganizationIntegrationConfigurationDetailsView]; +END +GO + +CREATE VIEW [dbo].[OrganizationIntegrationConfigurationDetailsView] +AS + SELECT + oi.[OrganizationId], + oi.[Type] AS [IntegrationType], + oic.[EventType], + oic.[Configuration], + oi.[Configuration] AS [IntegrationConfiguration], + oic.[Template] + FROM + [dbo].[OrganizationIntegrationConfiguration] oic + INNER JOIN + [dbo].[OrganizationIntegration] oi ON oi.[Id] = oic.[OrganizationIntegrationId] +GO + +IF OBJECT_ID('[dbo].[OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType]') IS NOT NULL + BEGIN + DROP PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadManyByEventTypeOrganizationIdIntegrationType] +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadManyByEventTypeOrganizationIdIntegrationType] + @EventType SMALLINT, + @OrganizationId UNIQUEIDENTIFIER, + @IntegrationType SMALLINT +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationDetailsView] oic + WHERE + oic.[EventType] = @EventType + AND + oic.[OrganizationId] = @OrganizationId + AND + oic.[IntegrationType] = @IntegrationType +END +GO From c154b6ad9b2db705834affcaf5c4c2ad3c4f125f Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Sun, 30 Mar 2025 12:57:05 -0400 Subject: [PATCH 38/43] Clean up remove-server-version-header feature flag (#5573) * Removed feature flag. * Linting. --- src/Core/Constants.cs | 1 - src/SharedWeb/Utilities/RequestLoggingMiddleware.cs | 10 ---------- 2 files changed, 11 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a4d3b2e13..7e39a586f4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -163,7 +163,6 @@ public static class FeatureFlagKeys public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string TrialPayment = "PM-8163-trial-payment"; - public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string NewDeviceVerification = "new-device-verification"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; diff --git a/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs b/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs index 77efdbfcf0..7f2db27eec 100644 --- a/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs +++ b/src/SharedWeb/Utilities/RequestLoggingMiddleware.cs @@ -1,5 +1,4 @@ using System.Collections; -using Bit.Core; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -25,15 +24,6 @@ public sealed class RequestLoggingMiddleware public Task Invoke(HttpContext context, IFeatureService featureService) { - if (!featureService.IsEnabled(FeatureFlagKeys.RemoveServerVersionHeader)) - { - context.Response.OnStarting(() => - { - context.Response.Headers.Append("Server-Version", AssemblyHelpers.GetVersion()); - return Task.CompletedTask; - }); - } - using (_logger.BeginScope( new RequestLogScope(context.GetIpAddress(_globalSettings), GetHeaderValue(context, "user-agent"), From ad05e3f9e157786c201e068971f79ca7dc6079df Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Sun, 30 Mar 2025 16:03:09 -0400 Subject: [PATCH 39/43] Complete feature flag grouping by team (#5574) * Completed grouping of feature flags by team. * Completed grouping feature flags by team. * Linting * Moved flag. * Moved ssh-key-vault-item to KM. --- src/Core/Constants.cs | 129 ++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7e39a586f4..ea360eb744 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -110,12 +110,79 @@ public static class FeatureFlagKeys public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; + /* Auth Team */ + public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; + public const string DuoRedirect = "duo-redirect"; + public const string EmailVerification = "email-verification"; + public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; + public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; + public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; + public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; + public const string NewDeviceVerification = "new-device-verification"; + public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; + public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; + public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; + + /* Autofill Team */ + public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; + public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; + public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; + public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; + public const string SSHAgent = "ssh-agent"; + public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override"; + public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor"; + public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2"; + public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements"; + public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain"; + public const string NotificationRefresh = "notification-refresh"; + public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; + public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; + public const string InlineMenuTotp = "inline-menu-totp"; + + /* Billing Team */ + public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; + public const string TrialPayment = "PM-8163-trial-payment"; + public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; + public const string UsePricingService = "use-pricing-service"; + public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; + public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; + public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; + + /* Key Management Team */ + public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; + public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; + public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; + public const string Argon2Default = "argon2-default"; + public const string UserkeyRotationV2 = "userkey-rotation-v2"; + public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; + + /* Mobile Team */ + public const string NativeCarouselFlow = "native-carousel-flow"; + public const string NativeCreateAccountFlow = "native-create-account-flow"; + public const string AndroidImportLoginsFlow = "import-logins-flow"; + public const string AppReviewPrompt = "app-review-prompt"; + public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android"; + public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios"; + public const string AndroidMutualTls = "mutual-tls"; + public const string SingleTapPasskeyCreation = "single-tap-passkey-creation"; + public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; + public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; + public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias"; + public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; + + /* Platform Team */ + public const string PersistPopupView = "persist-popup-view"; + public const string StorageReseedRefactor = "storage-reseed-refactor"; + public const string WebPush = "web-push"; + public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; + /* Tools Team */ public const string ItemShare = "item-share"; public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string ExportAttachments = "export-attachments"; + public const string GeneratorToolsModernization = "generator-tools-modernization"; /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; @@ -125,69 +192,7 @@ public static class FeatureFlagKeys public const string VaultBulkManagementAction = "vault-bulk-management-action"; public const string RestrictProviderAccess = "restrict-provider-access"; public const string SecurityTasks = "security-tasks"; - - /* Auth Team */ - public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; - - /* Key Management Team */ - public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; - public const string SSHAgent = "ssh-agent"; - public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override"; - public const string Argon2Default = "argon2-default"; - public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; - public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; - public const string UserkeyRotationV2 = "userkey-rotation-v2"; - - /* Unsorted */ - public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; - public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; - public const string DuoRedirect = "duo-redirect"; - public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; - public const string EmailVerification = "email-verification"; - public const string EmailVerificationDisableTimingDelays = "email-verification-disable-timing-delays"; - public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; - public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; - public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; - public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; - public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; - public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; - public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor"; - public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2"; - public const string NativeCarouselFlow = "native-carousel-flow"; - public const string NativeCreateAccountFlow = "native-create-account-flow"; - public const string NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements"; - public const string BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain"; - public const string NotificationRefresh = "notification-refresh"; - public const string PersistPopupView = "persist-popup-view"; public const string CipherKeyEncryption = "cipher-key-encryption"; - public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; - public const string StorageReseedRefactor = "storage-reseed-refactor"; - public const string TrialPayment = "PM-8163-trial-payment"; - public const string GeneratorToolsModernization = "generator-tools-modernization"; - public const string NewDeviceVerification = "new-device-verification"; - public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; - public const string InlineMenuTotp = "inline-menu-totp"; - public const string AppReviewPrompt = "app-review-prompt"; - public const string ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs"; - public const string UsePricingService = "use-pricing-service"; - public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; - public const string EnablePasswordManagerSyncAndroid = "enable-password-manager-sync-android"; - public const string EnablePasswordManagerSynciOS = "enable-password-manager-sync-ios"; - public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner"; - public const string SingleTapPasskeyCreation = "single-tap-passkey-creation"; - public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; - public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; - public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal"; - public const string AndroidMutualTls = "mutual-tls"; - public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; - public const string PM3503_MobileAnonAddySelfHostAlias = "anon-addy-self-host-alias"; - public const string WebPush = "web-push"; - public const string AndroidImportLoginsFlow = "import-logins-flow"; - public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; - public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; - public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; - public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; - public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public static List GetAllKeys() { From f60db791cc8d7b1efdd830362b85e9a137290de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 31 Mar 2025 12:25:36 +0100 Subject: [PATCH 40/43] [PM-19590] Add k6 load testing script for SyncController's /sync endpoint (#5508) * Add k6 load testing script for sync endpoint * Refactor sync response validation to use lowercase keys * Remove access token validation from sync.js * Update http_req_duration threshold in sync.js from 400ms to 1200ms --- perf/load/sync.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 perf/load/sync.js diff --git a/perf/load/sync.js b/perf/load/sync.js new file mode 100644 index 0000000000..5624803e84 --- /dev/null +++ b/perf/load/sync.js @@ -0,0 +1,90 @@ +import http from "k6/http"; +import { check, fail } from "k6"; +import { authenticate } from "./helpers/auth.js"; + +const IDENTITY_URL = __ENV.IDENTITY_URL; +const API_URL = __ENV.API_URL; +const CLIENT_ID = __ENV.CLIENT_ID; +const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; +const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; + +export const options = { + ext: { + loadimpact: { + projectID: 3639465, + name: "Sync", + }, + }, + scenarios: { + constant_load: { + executor: "constant-arrival-rate", + rate: 30, + timeUnit: "1m", // 0.5 requests / second + duration: "10m", + preAllocatedVUs: 5, + }, + ramping_load: { + executor: "ramping-arrival-rate", + startRate: 30, + timeUnit: "1m", // 0.5 requests / second to start + stages: [ + { duration: "30s", target: 30 }, + { duration: "2m", target: 75 }, + { duration: "1m", target: 60 }, + { duration: "2m", target: 100 }, + { duration: "2m", target: 90 }, + { duration: "1m", target: 120 }, + { duration: "30s", target: 150 }, + { duration: "30s", target: 60 }, + { duration: "30s", target: 0 }, + ], + preAllocatedVUs: 20, + }, + }, + thresholds: { + http_req_failed: ["rate<0.01"], + http_req_duration: ["p(95)<1200"], + }, +}; + +export function setup() { + return authenticate(IDENTITY_URL, CLIENT_ID, AUTH_USERNAME, AUTH_PASSWORD); +} + +export default function (data) { + const params = { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${data.access_token}`, + "X-ClientId": CLIENT_ID, + }, + tags: { name: "Sync" }, + }; + + const excludeDomains = Math.random() > 0.5; + + const syncRes = http.get(`${API_URL}/sync?excludeDomains=${excludeDomains}`, params); + if ( + !check(syncRes, { + "sync status is 200": (r) => r.status === 200, + }) + ) { + console.error(`Sync failed with status ${syncRes.status}: ${syncRes.body}`); + fail("sync status code was *not* 200"); + } + + if (syncRes.status === 200) { + const syncJson = syncRes.json(); + + check(syncJson, { + "sync response has profile": (j) => j.profile !== undefined, + "sync response has folders": (j) => Array.isArray(j.folders), + "sync response has collections": (j) => Array.isArray(j.collections), + "sync response has ciphers": (j) => Array.isArray(j.ciphers), + "sync response has policies": (j) => Array.isArray(j.policies), + "sync response has sends": (j) => Array.isArray(j.sends), + "sync response has correct object type": (j) => j.object === "sync" + }); + } +} From 887332b4366263abac08f72fe54f47270f8f1dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 31 Mar 2025 15:19:55 +0200 Subject: [PATCH 41/43] [PM-15127] Remove secrets requirement from build workflow (#5546) * [PM-15127] Remove secrets requirement from build workflow * Remove unneeded check, fix target workflow * Remove IF --- .github/CODEOWNERS | 1 + .github/workflows/build.yml | 52 +++++++++++++++++------------- .github/workflows/build_target.yml | 21 ++++++++++++ 3 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/build_target.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 11e79590f2..aa868cd1b5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -66,6 +66,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev # Platform team .github/workflows/build.yml @bitwarden/team-platform-dev +.github/workflows/build_target.yml @bitwarden/team-platform-dev .github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev .github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev .github/workflows/repository-management.yml @bitwarden/team-platform-dev diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f125b7811..f0df238b34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,22 +7,18 @@ on: - "main" - "rc" - "hotfix-rc" - pull_request_target: + pull_request: types: [opened, synchronize] + workflow_call: + inputs: {} env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" jobs: - check-run: - name: Check PR run - uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main - lint: name: Lint runs-on: ubuntu-22.04 - needs: - - check-run steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -40,6 +36,8 @@ jobs: runs-on: ubuntu-22.04 needs: - lint + outputs: + has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} strategy: fail-fast: false matrix: @@ -75,6 +73,14 @@ jobs: base_path: ./bitwarden_license/src node: true steps: + - name: Check secrets + id: check-secrets + env: + AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + run: | + has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -134,6 +140,7 @@ jobs: id-token: write needs: - build-artifacts + if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }} strategy: fail-fast: false matrix: @@ -227,7 +234,7 @@ jobs: - name: Generate Docker image tag id: tag run: | - if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g") else IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") @@ -289,11 +296,11 @@ jobs: "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" - name: Install Cosign - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 - name: Sign image with Cosign - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' env: DIGEST: ${{ steps.build-docker.outputs.digest }} TAGS: ${{ steps.image-tags.outputs.tags }} @@ -343,7 +350,7 @@ jobs: - name: Make Docker stubs if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') run: | # Set proper setup image based on branch @@ -385,7 +392,7 @@ jobs: - name: Make Docker stub checksums if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') run: | sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt @@ -393,7 +400,7 @@ jobs: - name: Upload Docker stub US artifact if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: @@ -403,7 +410,7 @@ jobs: - name: Upload Docker stub EU artifact if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: @@ -413,7 +420,7 @@ jobs: - name: Upload Docker stub US checksum artifact if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: @@ -423,7 +430,7 @@ jobs: - name: Upload Docker stub EU checksum artifact if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: @@ -552,7 +559,7 @@ jobs: self-host-build: name: Trigger self-host build if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') runs-on: ubuntu-22.04 needs: @@ -587,7 +594,7 @@ jobs: trigger-k8s-deploy: name: Trigger k8s deploy - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 needs: - build-docker @@ -623,7 +630,8 @@ jobs: trigger-ee-updates: name: Trigger Ephemeral Environment updates if: | - github.event_name == 'pull_request_target' + needs.build-artifacts.outputs.has_secrets == 'true' + && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') runs-on: ubuntu-24.04 needs: @@ -660,7 +668,8 @@ jobs: name: Trigger Ephemeral Environment Sync needs: trigger-ee-updates if: | - github.event_name == 'pull_request_target' + needs.build-artifacts.outputs.has_secrets == 'true' + && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ephemeral-environment') uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main with: @@ -670,7 +679,6 @@ jobs: pull_request_number: ${{ github.event.number }} secrets: inherit - check-failures: name: Check for failures if: always() @@ -686,7 +694,7 @@ jobs: steps: - name: Check if any job failed if: | - github.event_name != 'pull_request_target' + github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') run: exit 1 diff --git a/.github/workflows/build_target.yml b/.github/workflows/build_target.yml new file mode 100644 index 0000000000..313446c949 --- /dev/null +++ b/.github/workflows/build_target.yml @@ -0,0 +1,21 @@ +name: Build on PR Target + +on: + pull_request_target: + types: [opened, synchronize] + +defaults: + run: + shell: bash + +jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + + run-workflow: + name: Run Build on PR Target + needs: check-run + if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + uses: ./.github/workflows/build.yml + secrets: inherit From 786b0edcebd19c08992194e46886d5c37b8a85f9 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 31 Mar 2025 08:33:57 -0500 Subject: [PATCH 42/43] [PM-18527] - Fix allowing restored user to own multiple free orgs (#5444) * Moved RestoreUserAsync and RestoreUsersAsync to Command. * Fixing the bug. * Added test for bulk method. * Fixing sonar cube warning. * SonarQube warning fix. * Excluding org users we already have. * Fixed misspelling. Added integration test for method. * test had the misspelling as well :facepalm: * Split out interface. Added admin and confirmed constraints. * fixed queries and added xml comments and tests. --- .../Scim/Controllers/v2/UsersController.cs | 9 +- .../src/Scim/Users/PatchUserCommand.cs | 8 +- .../Scim.Test/Users/PatchUserCommandTests.cs | 7 +- .../OrganizationUsersController.cs | 10 +- .../v1/IRestoreOrganizationUserCommand.cs | 54 ++ .../v1/RestoreOrganizationUserCommand.cs | 295 ++++++++ .../Repositories/IOrganizationRepository.cs | 1 + .../Services/IOrganizationService.cs | 4 - .../Implementations/OrganizationService.cs | 144 +--- ...OrganizationServiceCollectionExtensions.cs | 3 + .../Repositories/OrganizationRepository.cs | 11 + .../Repositories/OrganizationRepository.cs | 13 + .../Organization_ReadManyByIds.sql | 67 ++ .../RestoreOrganizationUserCommandTests.cs | 693 ++++++++++++++++++ .../Services/OrganizationServiceTests.cs | 551 -------------- .../OrganizationRepositoryTests.cs | 39 + .../OrganizationRepositoryTests.cs | 33 + .../2025-03-21_00_Org_ReadManyByManyId.sql | 66 ++ 18 files changed, 1298 insertions(+), 710 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs create mode 100644 src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs create mode 100644 util/Migrator/DbScripts/2025-03-21_00_Org_ReadManyByManyId.sql diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index 1323205b96..77bc62e952 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -23,7 +24,7 @@ public class UsersController : Controller private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IPatchUserCommand _patchUserCommand; private readonly IPostUserCommand _postUserCommand; - private readonly ILogger _logger; + private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; public UsersController( IOrganizationUserRepository organizationUserRepository, @@ -32,7 +33,7 @@ public class UsersController : Controller IRemoveOrganizationUserCommand removeOrganizationUserCommand, IPatchUserCommand patchUserCommand, IPostUserCommand postUserCommand, - ILogger logger) + IRestoreOrganizationUserCommand restoreOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; _organizationService = organizationService; @@ -40,7 +41,7 @@ public class UsersController : Controller _removeOrganizationUserCommand = removeOrganizationUserCommand; _patchUserCommand = patchUserCommand; _postUserCommand = postUserCommand; - _logger = logger; + _restoreOrganizationUserCommand = restoreOrganizationUserCommand; } [HttpGet("{id}")] @@ -93,7 +94,7 @@ public class UsersController : Controller if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked) { - await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); + await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM); } else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked) { diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs index f4445354ce..3d7082aacc 100644 --- a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; @@ -11,15 +12,18 @@ public class PatchUserCommand : IPatchUserCommand { private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationService _organizationService; + private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly ILogger _logger; public PatchUserCommand( IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, + IRestoreOrganizationUserCommand restoreOrganizationUserCommand, ILogger logger) { _organizationUserRepository = organizationUserRepository; _organizationService = organizationService; + _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _logger = logger; } @@ -71,7 +75,7 @@ public class PatchUserCommand : IPatchUserCommand { if (active && orgUser.Status == OrganizationUserStatusType.Revoked) { - await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM); + await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM); return true; } else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked) diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs index 6e9c985b88..44a43d16b7 100644 --- a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -43,7 +44,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + await sutProvider.GetDependency().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); } [Theory] @@ -71,7 +72,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); + await sutProvider.GetDependency().Received(1).RestoreUserAsync(organizationUser, EventSystemUser.SCIM); } [Theory] @@ -147,7 +148,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); } diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index cfe93e87ce..5fd9109077 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; @@ -61,6 +62,7 @@ public class OrganizationUsersController : Controller private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; + private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; public OrganizationUsersController( IOrganizationRepository organizationRepository, @@ -86,7 +88,8 @@ public class OrganizationUsersController : Controller IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, IPricingClient pricingClient, - IConfirmOrganizationUserCommand confirmOrganizationUserCommand) + IConfirmOrganizationUserCommand confirmOrganizationUserCommand, + IRestoreOrganizationUserCommand restoreOrganizationUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -112,6 +115,7 @@ public class OrganizationUsersController : Controller _featureService = featureService; _pricingClient = pricingClient; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; + _restoreOrganizationUserCommand = restoreOrganizationUserCommand; } [HttpGet("{id}")] @@ -630,14 +634,14 @@ public class OrganizationUsersController : Controller [HttpPut("{id}/restore")] public async Task RestoreAsync(Guid orgId, Guid id) { - await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _organizationService.RestoreUserAsync(orgUser, userId)); + await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); } [HttpPatch("restore")] [HttpPut("restore")] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _organizationService.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); + return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); } [HttpPatch("enable-secrets-manager")] diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs new file mode 100644 index 0000000000..e5e5bfb482 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs @@ -0,0 +1,54 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; + +/// +/// Restores a user back to their previous status. +/// +public interface IRestoreOrganizationUserCommand +{ + /// + /// Validates that the requesting user can perform the action. There is also a check done to ensure the organization + /// can re-add this user based on their current occupied seats. + /// + /// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as + /// other organizations the user may belong to. + /// + /// Reference Events and Push Notifications are fired off for this as well. + /// + /// Revoked user to be restored. + /// UserId of the user performing the action. + Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId); + + /// + /// Validates that the requesting user can perform the action. There is also a check done to ensure the organization + /// can re-add this user based on their current occupied seats. + /// + /// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as + /// other organizations the user may belong to. + /// + /// Reference Events and Push Notifications are fired off for this as well. + /// + /// Revoked user to be restored. + /// System that is performing the action on behalf of the organization (Public API, SCIM, etc.) + Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); + + /// + /// Validates that the requesting user can perform the action. There is also a check done to ensure the organization + /// can re-add this user based on their current occupied seats. + /// + /// Checks are performed to make sure the user is conforming to all policies enforced by the organization as well as + /// other organizations the user may belong to. + /// + /// Reference Events and Push Notifications are fired off for this as well. + /// + /// Organization the users should be restored to. + /// List of organization user ids to restore to previous status. + /// UserId of the user performing the action. + /// Passed in from caller to avoid circular dependency + /// List of organization user Ids and strings. A successful restoration will have an empty string. + /// If an error occurs, the error message will be provided. + Task>> RestoreUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs new file mode 100644 index 0000000000..3d4b0fba5c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -0,0 +1,295 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; + +public class RestoreOrganizationUserCommand( + ICurrentContext currentContext, + IEventService eventService, + IFeatureService featureService, + IPushNotificationService pushNotificationService, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IPolicyService policyService, + IUserRepository userRepository, + IOrganizationService organizationService) : IRestoreOrganizationUserCommand +{ + public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) + { + if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value) + { + throw new BadRequestException("You cannot restore yourself."); + } + + if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && + !await currentContext.OrganizationOwner(organizationUser.OrganizationId)) + { + throw new BadRequestException("Only owners can restore other owners."); + } + + await RepositoryRestoreUserAsync(organizationUser); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + + if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && + organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + } + + public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser) + { + await RepositoryRestoreUserAsync(organizationUser); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, + systemUser); + + if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && + organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + } + + private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser) + { + if (organizationUser.Status != OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Already active."); + } + + var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + + if (availableSeats < 1) + { + await organizationService.AutoAddSeatsAsync(organization, 1); // Hooray + } + + var userTwoFactorIsEnabled = false; + // Only check 2FA status if the user is linked to a user account + if (organizationUser.UserId.HasValue) + { + userTwoFactorIsEnabled = + (await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([organizationUser.UserId.Value])) + .FirstOrDefault() + .twoFactorIsEnabled; + } + + await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser); + + await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); + + var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser); + + await organizationUserRepository.RestoreAsync(organizationUser.Id, status); + + organizationUser.Status = status; + } + + private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) + { + var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value); + var otherOrgs = await organizationRepository.GetManyByUserIdAsync(organizationUser.UserId.Value); + + var orgOrgUserDict = relatedOrgUsersFromOtherOrgs + .Where(x => x.Id != organizationUser.Id) + .ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId)); + + CheckForOtherFreeOrganizationOwnership(organizationUser, orgOrgUserDict); + } + + private async Task> GetRelatedOrganizationUsersAndOrganizations( + IEnumerable organizationUsers) + { + var allUserIds = organizationUsers.Select(x => x.UserId.Value); + + var otherOrganizationUsers = (await organizationUserRepository.GetManyByManyUsersAsync(allUserIds)) + .Where(x => organizationUsers.Any(y => y.Id == x.Id) == false); + + var otherOrgs = await organizationRepository.GetManyByIdsAsync(otherOrganizationUsers + .Select(x => x.OrganizationId) + .Distinct()); + + return otherOrganizationUsers + .ToDictionary(x => x, x => otherOrgs.FirstOrDefault(y => y.Id == x.OrganizationId)); + } + + private static void CheckForOtherFreeOrganizationOwnership(OrganizationUser organizationUser, + Dictionary otherOrgUsersAndOrgs) + { + var ownerOrAdminList = new[] { OrganizationUserType.Owner, OrganizationUserType.Admin }; + if (otherOrgUsersAndOrgs.Any(x => + x.Key.UserId == organizationUser.UserId && + ownerOrAdminList.Any(userType => userType == x.Key.Type) && + x.Key.Status == OrganizationUserStatusType.Confirmed && + x.Value.PlanType == PlanType.Free)) + { + throw new BadRequestException( + "User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account."); + } + } + + public async Task>> RestoreUsersAsync(Guid organizationId, + IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService) + { + var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); + var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) + .ToList(); + + if (filteredUsers.Count == 0) + { + throw new BadRequestException("Users invalid."); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var newSeatsRequired = organizationUserIds.Count() - availableSeats; + await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); + + var deletingUserIsOwner = false; + if (restoringUserId.HasValue) + { + deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId); + } + + // Query Two Factor Authentication status for all users in the organization + // This is an optimization to avoid querying the Two Factor Authentication status for each user individually + var organizationUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync( + filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value)); + + var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizations(filteredUsers); + + var result = new List>(); + + foreach (var organizationUser in filteredUsers) + { + try + { + if (organizationUser.Status != OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Already active."); + } + + if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId) + { + throw new BadRequestException("You cannot restore yourself."); + } + + if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && + !deletingUserIsOwner) + { + throw new BadRequestException("Only owners can restore other owners."); + } + + var twoFactorIsEnabled = organizationUser.UserId.HasValue + && organizationUsersTwoFactorEnabled + .FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value) + .twoFactorIsEnabled; + + await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled); + + CheckForOtherFreeOrganizationOwnership(organizationUser, orgUsersAndOrgs); + + var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser); + + await organizationUserRepository.RestoreAsync(organizationUser.Id, status); + organizationUser.Status = status; + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + if (featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && + organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + + result.Add(Tuple.Create(organizationUser, "")); + } + catch (BadRequestException e) + { + result.Add(Tuple.Create(organizationUser, e.Message)); + } + } + + return result; + } + + private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled) + { + // An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant + // The user will be subject to the same checks when they try to accept the invite + if (OrganizationService.GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited) + { + return; + } + + var userId = orgUser.UserId.Value; + + // Enforce Single Organization Policy of organization user is being restored to + var allOrgUsers = await organizationUserRepository.GetManyByUserAsync(userId); + var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); + var singleOrgPoliciesApplyingToRevokedUsers = await policyService.GetPoliciesApplicableToUserAsync(userId, + PolicyType.SingleOrg, OrganizationUserStatusType.Revoked); + var singleOrgPolicyApplies = + singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId); + + var singleOrgCompliant = true; + var belongsToOtherOrgCompliant = true; + var twoFactorCompliant = true; + + if (hasOtherOrgs && singleOrgPolicyApplies) + { + singleOrgCompliant = false; + } + + // Enforce Single Organization Policy of other organizations user is a member of + var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + belongsToOtherOrgCompliant = false; + } + + // Enforce 2FA Policy of organization user is trying to join + if (!userHasTwoFactorEnabled) + { + var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); + if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) + { + twoFactorCompliant = false; + } + } + + var user = await userRepository.GetByIdAsync(userId); + + if (!singleOrgCompliant && !twoFactorCompliant) + { + throw new BadRequestException(user.Email + + " is not compliant with the single organization and two-step login policy"); + } + else if (!singleOrgCompliant) + { + throw new BadRequestException(user.Email + " is not compliant with the single organization policy"); + } + else if (!belongsToOtherOrgCompliant) + { + throw new BadRequestException(user.Email + + " belongs to an organization that doesn't allow them to join multiple organizations"); + } + else if (!twoFactorCompliant) + { + throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); + } + } +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 584d95ffe2..7e315ed58b 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -24,4 +24,5 @@ public interface IOrganizationRepository : IRepository /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); + Task> GetManyByIdsAsync(IEnumerable ids); } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 476fccb480..e0088f1f74 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -48,10 +48,6 @@ public interface IOrganizationService Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); Task>> RevokeUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? revokingUserId); - Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId); - Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); - Task>> RestoreUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService); Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); /// /// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'. diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index ab5703eaa1..64bd434327 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -19,7 +19,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -75,7 +74,6 @@ public class OrganizationService : IOrganizationService private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IOrganizationBillingService _organizationBillingService; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; @@ -112,7 +110,6 @@ public class OrganizationService : IOrganizationService IProviderRepository providerRepository, IFeatureService featureService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IOrganizationBillingService organizationBillingService, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery) @@ -148,7 +145,6 @@ public class OrganizationService : IOrganizationService _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _organizationBillingService = organizationBillingService; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; @@ -1891,144 +1887,6 @@ public class OrganizationService : IOrganizationService return result; } - public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) - { - if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value) - { - throw new BadRequestException("You cannot restore yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && - !await _currentContext.OrganizationOwner(organizationUser.OrganizationId)) - { - throw new BadRequestException("Only owners can restore other owners."); - } - - await RepositoryRestoreUserAsync(organizationUser); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); - - if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - } - - public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser) - { - await RepositoryRestoreUserAsync(organizationUser); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser); - - if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - } - - private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser) - { - if (organizationUser.Status != OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already active."); - } - - var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; - if (availableSeats < 1) - { - await AutoAddSeatsAsync(organization, 1); - } - - var userTwoFactorIsEnabled = false; - // Only check Two Factor Authentication status if the user is linked to a user account - if (organizationUser.UserId.HasValue) - { - userTwoFactorIsEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(new[] { organizationUser.UserId.Value })).FirstOrDefault().twoFactorIsEnabled; - } - - await CheckPoliciesBeforeRestoreAsync(organizationUser, userTwoFactorIsEnabled); - - var status = GetPriorActiveOrganizationUserStatusType(organizationUser); - - await _organizationUserRepository.RestoreAsync(organizationUser.Id, status); - organizationUser.Status = status; - } - - public async Task>> RestoreUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUserIds); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - - var organization = await _organizationRepository.GetByIdAsync(organizationId); - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; - var newSeatsRequired = organizationUserIds.Count() - availableSeats; - await AutoAddSeatsAsync(organization, newSeatsRequired); - - var deletingUserIsOwner = false; - if (restoringUserId.HasValue) - { - deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); - } - - // Query Two Factor Authentication status for all users in the organization - // This is an optimization to avoid querying the Two Factor Authentication status for each user individually - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync( - filteredUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId.Value)); - - var result = new List>(); - - foreach (var organizationUser in filteredUsers) - { - try - { - if (organizationUser.Status != OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already active."); - } - - if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId) - { - throw new BadRequestException("You cannot restore yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && restoringUserId.HasValue && !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can restore other owners."); - } - - var twoFactorIsEnabled = organizationUser.UserId.HasValue - && organizationUsersTwoFactorEnabled.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value).twoFactorIsEnabled; - await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled); - - var status = GetPriorActiveOrganizationUserStatusType(organizationUser); - - await _organizationUserRepository.RestoreAsync(organizationUser.Id, status); - organizationUser.Status = status; - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); - if (_featureService.IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) && organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - - result.Add(Tuple.Create(organizationUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(organizationUser, e.Message)); - } - } - - return result; - } - private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled) { // An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant @@ -2095,7 +1953,7 @@ public class OrganizationService : IOrganizationService } } - static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) + public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) { // Determine status to revert back to var status = OrganizationUserStatusType.Invited; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index e13a06f660..59cfdace65 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; 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.RestoreUser.v1; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -168,6 +169,8 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index f624f7da28..3da8ad1a6c 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -196,4 +196,15 @@ public class OrganizationRepository : Repository, IOrganizat return result.ToList(); } } + + public async Task> GetManyByIdsAsync(IEnumerable ids) + { + await using var connection = new SqlConnection(ConnectionString); + + return (await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadManyByIds]", + new { OrganizationIds = ids.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure)) + .ToList(); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 6fc42b699d..c095b07030 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -354,6 +354,19 @@ public class OrganizationRepository : Repository> GetManyByIdsAsync(IEnumerable ids) + { + using var scope = ServiceScopeFactory.CreateScope(); + + var dbContext = GetDatabaseContext(scope); + + var query = from organization in dbContext.Organizations + where ids.Contains(organization.Id) + select organization; + + return await query.ToArrayAsync(); + } + public Task EnableCollectionEnhancements(Guid organizationId) { throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework."); diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql new file mode 100644 index 0000000000..23f1f578d0 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadManyByIds.sql @@ -0,0 +1,67 @@ +CREATE PROCEDURE [dbo].[Organization_ReadManyByIds] @OrganizationIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT o.[Id], + o.[Identifier], + o.[Name], + o.[BusinessName], + o.[BusinessAddress1], + o.[BusinessAddress2], + o.[BusinessAddress3], + o.[BusinessCountry], + o.[BusinessTaxNumber], + o.[BillingEmail], + o.[Plan], + o.[PlanType], + o.[Seats], + o.[MaxCollections], + o.[UsePolicies], + o.[UseSso], + o.[UseGroups], + o.[UseDirectory], + o.[UseEvents], + o.[UseTotp], + o.[Use2fa], + o.[UseApi], + o.[UseResetPassword], + o.[SelfHost], + o.[UsersGetPremium], + o.[Storage], + o.[MaxStorageGb], + o.[Gateway], + o.[GatewayCustomerId], + o.[GatewaySubscriptionId], + o.[ReferenceData], + o.[Enabled], + o.[LicenseKey], + o.[PublicKey], + o.[PrivateKey], + o.[TwoFactorProviders], + o.[ExpirationDate], + o.[CreationDate], + o.[RevisionDate], + o.[OwnersNotifiedOfAutoscaling], + o.[MaxAutoscaleSeats], + o.[UseKeyConnector], + o.[UseScim], + o.[UseCustomPermissions], + o.[UseSecretsManager], + o.[Status], + o.[UsePasswordManager], + o.[SmSeats], + o.[SmServiceAccounts], + o.[MaxAutoscaleSmSeats], + o.[MaxAutoscaleSmServiceAccounts], + o.[SecretsManagerBeta], + o.[LimitCollectionCreation], + o.[LimitCollectionDeletion], + o.[LimitItemDeletion], + o.[AllowAdminAccessToAllCollectionItems], + o.[UseRiskInsights] + FROM [dbo].[OrganizationView] o + INNER JOIN @OrganizationIds ids ON o.[Id] = ids.[Id] + +END + diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..726664849d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -0,0 +1,693 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Enums; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser; + +[SutProviderCustomize] +public class RestoreOrganizationUserCommandTests +{ + [Theory, BitAutoData] + public async Task RestoreUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) + { + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) + .Returns(true); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(organizationUser.UserId!.Value); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) + { + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithEventSystemUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) + { + RestoreUser_Setup(organization, null, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) + .Returns(true); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser); + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(organizationUser.UserId!.Value); + } + + [Theory, BitAutoData] + public async Task RestoreUser_RestoreThemselves_Fails(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) + { + organizationUser.UserId = owner.Id; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("you cannot restore yourself", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task RestoreUser_AdminRestoreOwner_Fails(OrganizationUserType restoringUserType, + Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser restoringUser, + [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser organizationUser, SutProvider sutProvider) + { + restoringUser.Type = restoringUserType; + RestoreUser_Setup(organization, restoringUser, organizationUser, sutProvider); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id)); + + Assert.Contains("only owners can restore other owners", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + public async Task RestoreUser_WithStatusOtherThanRevoked_Fails(OrganizationUserStatusType userStatus, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser] OrganizationUser organizationUser, SutProvider sutProvider) + { + organizationUser.Status = userStatus; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("already active", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(true); + + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) }); + + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_With2FAPolicyEnabled_WithUser2FAConfigured_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetManyByUserAsync(organizationUser.UserId.Value) + .Returns(new[] { organizationUser, secondOrganizationUser }); + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(new[] + { + new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } + }); + + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(true); + + var user = new User { Email = "test@bitwarden.com" }; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetManyByUserAsync(organizationUser.UserId.Value) + .Returns(new[] { organizationUser, secondOrganizationUser }); + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(new[] + { + new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } + }); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([ + new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication, OrganizationUserStatus = OrganizationUserStatusType.Revoked } + ]); + + var user = new User { Email = "test@bitwarden.com" }; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; + + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } + ]); + + var user = new User { Email = "test@bitwarden.com" }; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RestoreAsync(Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithUser2FAConfigured_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } + ]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WhenUserOwningAnotherFreeOrganization_ThenRestoreUserFails( + Organization organization, + Organization otherOrganization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserOwnerFromDifferentOrg, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + + orgUserOwnerFromDifferentOrg.UserId = organizationUser.UserId; + otherOrganization.Id = orgUserOwnerFromDifferentOrg.OrganizationId; + otherOrganization.PlanType = PlanType.Free; + + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .GetManyByUserAsync(organizationUser.UserId.Value) + .Returns([orgUserOwnerFromDifferentOrg]); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(organizationUser.UserId.Value) + .Returns([otherOrganization]); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } + ]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Equal("User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Success(Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + var userService = Substitute.For(); + + orgUser1.Email = orgUser2.Email = null; // Mock that users were previously confirmed + orgUser1.OrganizationId = orgUser2.OrganizationId = organization.Id; + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + twoFactorIsEnabledQuery + .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, false) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id }, owner.Id, userService); + + // Assert + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Empty(r.Item2)); // No error messages + await organizationUserRepository + .Received(1) + .RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed); + await organizationUserRepository + .Received(1) + .RestoreAsync(orgUser2.Id, OrganizationUserStatusType.Confirmed); + await eventService.Received(1) + .LogOrganizationUserEventAsync(orgUser1, EventType.OrganizationUser_Restored); + await eventService.Received(1) + .LogOrganizationUserEventAsync(orgUser2, EventType.OrganizationUser_Restored); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_With2FAPolicy_BlocksNonCompliantUser(Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser3, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + + orgUser1.Email = orgUser2.Email = null; + orgUser3.UserId = null; + orgUser3.Key = null; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organization.Id; + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id))) + .Returns(new[] { orgUser1, orgUser2, orgUser3 }); + + userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" }); + + // Setup 2FA policy + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication }]); + + // User1 has 2FA, User2 doesn't + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, false) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + + // Assert + Assert.Equal(3, result.Count); + Assert.Empty(result[0].Item2); // First user should succeed + Assert.Contains("two-step login", result[1].Item2); // Second user should fail + Assert.Empty(result[2].Item2); // Third user should succeed + await organizationUserRepository + .Received(1) + .RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed); + await organizationUserRepository + .DidNotReceive() + .RestoreAsync(orgUser2.Id, Arg.Any()); + await organizationUserRepository + .Received(1) + .RestoreAsync(orgUser3.Id, OrganizationUserStatusType.Invited); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_UserOwnsAnotherFreeOrganization_BlocksOwnerUserFromBeingRestored(Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser3, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUserFromOtherOrg, + Organization otherOrganization, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var policyService = sutProvider.GetDependency(); + var userService = Substitute.For(); + + orgUser1.Email = orgUser2.Email = null; + orgUser3.UserId = null; + orgUser3.Key = null; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organization.Id; + + orgUserFromOtherOrg.UserId = orgUser1.UserId; + otherOrganization.Id = orgUserFromOtherOrg.OrganizationId; + otherOrganization.PlanType = PlanType.Free; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id))) + .Returns(new[] { orgUser1, orgUser2, orgUser3 }); + + userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" }); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([orgUserFromOtherOrg]); + + sutProvider.GetDependency() + .GetManyByIdsAsync(Arg.Is>(ids => ids.Contains(orgUserFromOtherOrg.OrganizationId))) + .Returns([otherOrganization]); + + + // Setup 2FA policy + policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns([new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication }]); + + // User1 has 2FA, User2 doesn't + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, false) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + + // Assert + Assert.Equal(3, result.Count); + Assert.Contains("owner", result[0].Item2); // Owner should fail + await organizationUserRepository + .DidNotReceive() + .RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed); + } + + private static void RestoreUser_Setup( + Organization organization, + OrganizationUser? requestingOrganizationUser, + OrganizationUser targetOrganizationUser, + SutProvider sutProvider) + { + if (requestingOrganizationUser != null) + { + requestingOrganizationUser.OrganizationId = organization.Id; + } + targetOrganizationUser.OrganizationId = organization.Id; + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner); + sutProvider.GetDependency().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 82dc0e2ebe..bd8ae1daaf 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -1,14 +1,11 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Context; @@ -1233,451 +1230,6 @@ OrganizationUserInvite invite, SutProvider sutProvider) .PushSyncOrgKeysAsync(organizationUser.UserId!.Value); } - [Theory, BitAutoData] - public async Task RestoreUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) - { - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); - - await sutProvider.GetDependency() - .Received(1) - .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); - } - - [Theory, BitAutoData] - public async Task RestoreUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) - { - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) - .Returns(true); - - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); - - await sutProvider.GetDependency() - .Received(1) - .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); - await sutProvider.GetDependency() - .Received(1) - .PushSyncOrgKeysAsync(organizationUser.UserId!.Value); - } - - [Theory, BitAutoData] - public async Task RestoreUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) - { - RestoreRevokeUser_Setup(organization, null, organizationUser, sutProvider); - - await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser); - - await sutProvider.GetDependency() - .Received(1) - .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser); - } - - [Theory, BitAutoData] - public async Task RestoreUser_WithEventSystemUser_WithPushSyncOrgKeysOnRevokeRestoreEnabled_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) - { - RestoreRevokeUser_Setup(organization, null, organizationUser, sutProvider); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PushSyncOrgKeysOnRevokeRestore) - .Returns(true); - - await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser); - - await sutProvider.GetDependency() - .Received(1) - .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, eventSystemUser); - await sutProvider.GetDependency() - .Received(1) - .PushSyncOrgKeysAsync(organizationUser.UserId!.Value); - } - - [Theory, BitAutoData] - public async Task RestoreUser_RestoreThemselves_Fails(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider) - { - organizationUser.UserId = owner.Id; - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("you cannot restore yourself", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Custom)] - public async Task RestoreUser_AdminRestoreOwner_Fails(OrganizationUserType restoringUserType, - Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser restoringUser, - [OrganizationUser(OrganizationUserStatusType.Revoked, OrganizationUserType.Owner)] OrganizationUser organizationUser, SutProvider sutProvider) - { - restoringUser.Type = restoringUserType; - RestoreRevokeUser_Setup(organization, restoringUser, organizationUser, sutProvider); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id)); - - Assert.Contains("only owners can restore other owners", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory] - [BitAutoData(OrganizationUserStatusType.Invited)] - [BitAutoData(OrganizationUserStatusType.Accepted)] - [BitAutoData(OrganizationUserStatusType.Confirmed)] - public async Task RestoreUser_WithStatusOtherThanRevoked_Fails(OrganizationUserStatusType userStatus, Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser] OrganizationUser organizationUser, SutProvider sutProvider) - { - organizationUser.Status = userStatus; - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("already active", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(true); - - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) }); - - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); - - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_With2FAPolicyEnabled_WithUser2FAConfigured_Success( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); - - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); - - await sutProvider.GetDependency() - .Received(1) - .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); - } - - [Theory, BitAutoData] - public async Task RestoreUser_WithSingleOrgPolicyEnabled_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - secondOrganizationUser.UserId = organizationUser.UserId; - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .GetManyByUserAsync(organizationUser.UserId.Value) - .Returns(new[] { organizationUser, secondOrganizationUser }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(new[] - { - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - }); - - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_vNext_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - secondOrganizationUser.UserId = organizationUser.UserId; - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); - - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(true); - - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - secondOrganizationUser.UserId = organizationUser.UserId; - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .GetManyByUserAsync(organizationUser.UserId.Value) - .Returns(new[] { organizationUser, secondOrganizationUser }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(new[] - { - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - }); - - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns(new[] - { - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - }); - - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login polciy", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; - - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); - - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - - Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithUser2FAConfigured_Success( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); - - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); - - await sutProvider.GetDependency() - .Received(1) - .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed); - await sutProvider.GetDependency() - .Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); - } - [Theory] [BitAutoData(PlanType.TeamsAnnually)] [BitAutoData(PlanType.TeamsMonthly)] @@ -1991,107 +1543,4 @@ OrganizationUserInvite invite, SutProvider sutProvider) } ); } - - [Theory, BitAutoData] - public async Task RestoreUsers_Success(Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, - SutProvider sutProvider) - { - // Arrange - RestoreRevokeUser_Setup(organization, owner, orgUser1, sutProvider); - var organizationUserRepository = sutProvider.GetDependency(); - var eventService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); - var userService = Substitute.For(); - - orgUser1.Email = orgUser2.Email = null; // Mock that users were previously confirmed - orgUser1.OrganizationId = orgUser2.OrganizationId = organization.Id; - organizationUserRepository - .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) - .Returns(new[] { orgUser1, orgUser2 }); - - twoFactorIsEnabledQuery - .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> - { - (orgUser1.UserId!.Value, true), - (orgUser2.UserId!.Value, false) - }); - - // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id }, owner.Id, userService); - - // Assert - Assert.Equal(2, result.Count); - Assert.All(result, r => Assert.Empty(r.Item2)); // No error messages - await organizationUserRepository - .Received(1) - .RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed); - await organizationUserRepository - .Received(1) - .RestoreAsync(orgUser2.Id, OrganizationUserStatusType.Confirmed); - await eventService.Received(1) - .LogOrganizationUserEventAsync(orgUser1, EventType.OrganizationUser_Restored); - await eventService.Received(1) - .LogOrganizationUserEventAsync(orgUser2, EventType.OrganizationUser_Restored); - } - - [Theory, BitAutoData] - public async Task RestoreUsers_With2FAPolicy_BlocksNonCompliantUser(Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser3, - SutProvider sutProvider) - { - // Arrange - RestoreRevokeUser_Setup(organization, owner, orgUser1, sutProvider); - var organizationUserRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var userService = Substitute.For(); - - orgUser1.Email = orgUser2.Email = null; - orgUser3.UserId = null; - orgUser3.Key = null; - orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organization.Id; - organizationUserRepository - .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id))) - .Returns(new[] { orgUser1, orgUser2, orgUser3 }); - - userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" }); - - // Setup 2FA policy - policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication } }); - - // User1 has 2FA, User2 doesn't - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> - { - (orgUser1.UserId!.Value, true), - (orgUser2.UserId!.Value, false) - }); - - // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id, orgUser3.Id }, owner.Id, userService); - - // Assert - Assert.Equal(3, result.Count); - Assert.Empty(result[0].Item2); // First user should succeed - Assert.Contains("two-step login", result[1].Item2); // Second user should fail - Assert.Empty(result[2].Item2); // Third user should succeed - await organizationUserRepository - .Received(1) - .RestoreAsync(orgUser1.Id, OrganizationUserStatusType.Confirmed); - await organizationUserRepository - .DidNotReceive() - .RestoreAsync(orgUser2.Id, Arg.Any()); - await organizationUserRepository - .Received(1) - .RestoreAsync(orgUser3.Id, OrganizationUserStatusType.Invited); - } } diff --git a/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs index f6d4227e2b..e8bafaea5b 100644 --- a/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -196,4 +196,43 @@ public class OrganizationRepositoryTests Assert.Single(sqlResult); Assert.True(sqlResult.All(o => o.Name == org.Name)); } + + [CiSkippedTheory, EfOrganizationAutoData] + public async Task GetManyByIdsAsync_Works_DataMatches(List organizations, + SqlRepo.OrganizationRepository sqlOrganizationRepo, + List suts) + { + var returnedOrgs = new List(); + + foreach (var sut in suts) + { + _ = await sut.CreateMany(organizations); + sut.ClearChangeTracking(); + + var efReturnedOrgs = await sut.GetManyByIdsAsync(organizations.Select(o => o.Id).ToList()); + returnedOrgs.AddRange(efReturnedOrgs); + } + + foreach (var organization in organizations) + { + var postSqlOrg = await sqlOrganizationRepo.CreateAsync(organization); + returnedOrgs.Add(await sqlOrganizationRepo.GetByIdAsync(postSqlOrg.Id)); + } + + var orgIds = organizations.Select(o => o.Id).ToList(); + var distinctReturnedOrgIds = returnedOrgs.Select(o => o.Id).Distinct().ToList(); + + Assert.Equal(orgIds.Count, distinctReturnedOrgIds.Count); + Assert.Equivalent(orgIds, distinctReturnedOrgIds); + + // clean up + foreach (var organization in organizations) + { + await sqlOrganizationRepo.DeleteAsync(organization); + foreach (var sut in suts) + { + await sut.DeleteAsync(organization); + } + } + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index f7c61ad957..a95778b199 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -253,4 +253,37 @@ public class OrganizationRepositoryTests Assert.Empty(result); } + + + [DatabaseTheory, DatabaseData] + public async Task GetManyByIdsAsync_ExistingOrganizations_ReturnsOrganizations(IOrganizationRepository organizationRepository) + { + var email = "test@email.com"; + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 1", + BillingEmail = email, + Plan = "Test", + PrivateKey = "privatekey1" + }); + + var organization2 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org 2", + BillingEmail = email, + Plan = "Test", + PrivateKey = "privatekey2" + }); + + var result = await organizationRepository.GetManyByIdsAsync([organization1.Id, organization2.Id]); + + Assert.Equal(2, result.Count); + Assert.Contains(result, org => org.Id == organization1.Id); + Assert.Contains(result, org => org.Id == organization2.Id); + + // Clean up + await organizationRepository.DeleteAsync(organization1); + await organizationRepository.DeleteAsync(organization2); + } } diff --git a/util/Migrator/DbScripts/2025-03-21_00_Org_ReadManyByManyId.sql b/util/Migrator/DbScripts/2025-03-21_00_Org_ReadManyByManyId.sql new file mode 100644 index 0000000000..fb58d3cff7 --- /dev/null +++ b/util/Migrator/DbScripts/2025-03-21_00_Org_ReadManyByManyId.sql @@ -0,0 +1,66 @@ +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadManyByIds] @OrganizationIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT o.[Id], + o.[Identifier], + o.[Name], + o.[BusinessName], + o.[BusinessAddress1], + o.[BusinessAddress2], + o.[BusinessAddress3], + o.[BusinessCountry], + o.[BusinessTaxNumber], + o.[BillingEmail], + o.[Plan], + o.[PlanType], + o.[Seats], + o.[MaxCollections], + o.[UsePolicies], + o.[UseSso], + o.[UseGroups], + o.[UseDirectory], + o.[UseEvents], + o.[UseTotp], + o.[Use2fa], + o.[UseApi], + o.[UseResetPassword], + o.[SelfHost], + o.[UsersGetPremium], + o.[Storage], + o.[MaxStorageGb], + o.[Gateway], + o.[GatewayCustomerId], + o.[GatewaySubscriptionId], + o.[ReferenceData], + o.[Enabled], + o.[LicenseKey], + o.[PublicKey], + o.[PrivateKey], + o.[TwoFactorProviders], + o.[ExpirationDate], + o.[CreationDate], + o.[RevisionDate], + o.[OwnersNotifiedOfAutoscaling], + o.[MaxAutoscaleSeats], + o.[UseKeyConnector], + o.[UseScim], + o.[UseCustomPermissions], + o.[UseSecretsManager], + o.[Status], + o.[UsePasswordManager], + o.[SmSeats], + o.[SmServiceAccounts], + o.[MaxAutoscaleSmSeats], + o.[MaxAutoscaleSmServiceAccounts], + o.[SecretsManagerBeta], + o.[LimitCollectionCreation], + o.[LimitCollectionDeletion], + o.[LimitItemDeletion], + o.[AllowAdminAccessToAllCollectionItems], + o.[UseRiskInsights] + FROM [dbo].[OrganizationView] o + INNER JOIN @OrganizationIds ids ON o.[Id] = ids.[Id] +END + From 683ade9ffc3887623af43b2a30b8f0933d01733d Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:49:14 -0400 Subject: [PATCH 43/43] feat(EF WebAuthnCreds Repo): [Auth/PM-19629] EF WebAuthnCredentialRepository.cs - Rewrite query to avoid reading entire table into memory (#5567) --- .../Auth/Repositories/WebAuthnCredentialRepository.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs index e198a5f79d..ca32c44211 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/WebAuthnCredentialRepository.cs @@ -68,12 +68,11 @@ public class WebAuthnCredentialRepository : Repository wc.Id == wc.Id) + + var newCredIds = newCreds.Select(nwc => nwc.Id).ToList(); + var validUserWebauthnCredentials = await GetDbSet(dbContext) + .Where(wc => wc.UserId == userId && newCredIds.Contains(wc.Id)) .ToListAsync(); - var validUserWebauthnCredentials = userWebauthnCredentials - .Where(wc => newCreds.Any(nwc => nwc.Id == wc.Id)) - .Where(wc => wc.UserId == userId); foreach (var wc in validUserWebauthnCredentials) {