From eed856cc0486fb4690b76693d8c7edd96e75cb98 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Fri, 28 Nov 2025 06:10:50 -0500 Subject: [PATCH 01/89] Move Azure.Extensions.AspNetCore.DataProtection.Blobs to Platform (#5442) --- .github/renovate.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 6a23a7e832..34b59db925 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -63,7 +63,6 @@ }, { matchPackageNames: [ - "Azure.Extensions.AspNetCore.DataProtection.Blobs", "DuoUniversal", "Fido2.AspNet", "Duende.IdentityServer", @@ -137,6 +136,7 @@ "AspNetCoreRateLimit", "AspNetCoreRateLimit.Redis", "Azure.Data.Tables", + "Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Messaging.EventGrid", "Azure.Messaging.ServiceBus", "Azure.Storage.Blobs", From 3ad486068d17c275f7f7d84b073b8170df8a65b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:33:51 +0100 Subject: [PATCH 02/89] [deps] Platform: Update azure azure-sdk-for-net monorepo (#5735) * [deps] Platform: Update azure azure-sdk-for-net monorepo * Remove bump of Azure.Extensions.AspNetCore.DataProtection.Blobs because it require Microsoft.AspNetCore.DataProtection >= 8.0.11 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith --- src/Api/Api.csproj | 2 +- src/Core/Core.csproj | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 138549e92d..48fedfc8c1 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 1be6e52854..319ddc26af 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -25,12 +25,12 @@ - + - - - + + + From 3133dc91aee000dbc6e6d78a4feb64cf47a6ec11 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:36:01 +0100 Subject: [PATCH 03/89] [deps]: Update MartinCostello.Logging.XUnit to 0.7.0 (#6046) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- test/Core.IntegrationTest/Core.IntegrationTest.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj index d964452f4c..3266b931d0 100644 --- a/test/Core.IntegrationTest/Core.IntegrationTest.csproj +++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj @@ -11,7 +11,7 @@ - + From 6a5430ff0a5999f8c9b98f0961f7f541a2f4b6ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:46:08 +0100 Subject: [PATCH 04/89] [deps] Platform: Update quartznet monorepo to 3.15.1 (#6211) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 319ddc26af..e26cc26b4a 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -60,9 +60,9 @@ - - - + + + From a17f94e15064d1acd0c2da6b0dea1938f2e2c6e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:10:39 +0100 Subject: [PATCH 05/89] [deps] Billing: Update xunit.runner.visualstudio to v3 (#5742) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- test/Core.IntegrationTest/Core.IntegrationTest.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj index 3266b931d0..babe974ffd 100644 --- a/test/Core.IntegrationTest/Core.IntegrationTest.csproj +++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj @@ -15,7 +15,7 @@ - + From 7cbc50de9885dd03987002c055408f7471c38151 Mon Sep 17 00:00:00 2001 From: Usman Date: Fri, 28 Nov 2025 20:15:06 +0500 Subject: [PATCH 06/89] Removed unused global settings and listenerlogger and passed cancelationtoken to Task.Delay methods (#1810) Co-authored-by: Daniel James Smith --- src/Admin/HostedServices/DatabaseMigrationHostedService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs index 434c265f26..219e6846bd 100644 --- a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs +++ b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs @@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable public virtual async Task StartAsync(CancellationToken cancellationToken) { // Wait 20 seconds to allow database to come online - await Task.Delay(20000); + await Task.Delay(20000, cancellationToken); var maxMigrationAttempts = 10; for (var i = 1; i <= maxMigrationAttempts; i++) @@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable { _logger.LogError(e, "Database unavailable for migration. Trying again (attempt #{0})...", i + 1); - await Task.Delay(20000); + await Task.Delay(20000, cancellationToken); } } } From 480c20a480718addf2772eb3eebcca1cf21c13d4 Mon Sep 17 00:00:00 2001 From: Jim Hays Date: Fri, 28 Nov 2025 10:45:23 -0500 Subject: [PATCH 07/89] [PM-1968] Spellcheck bugs (#2877) * Bug fix: 'captchResponse' -> 'captchaResponse' * Bug fix: 'GoupUser' -> 'GroupUser' * Bug fix: 'Cateogry' -> 'Category' --------- Co-authored-by: Daniel James Smith --- test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs b/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs index d8e944d3b8..a2fc5b19de 100644 --- a/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs +++ b/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs @@ -74,7 +74,7 @@ public class SendGridMailDeliveryServiceTests : IDisposable Assert.Equal(mailMessage.HtmlContent, msg.HtmlContent); Assert.Equal(mailMessage.TextContent, msg.PlainTextContent); - Assert.Contains("type:Cateogry", msg.Categories); + Assert.Contains("type:Category", msg.Categories); Assert.Contains(msg.Categories, x => x.StartsWith("env:")); Assert.Contains(msg.Categories, x => x.StartsWith("sender:")); From f151abee54b1b7e3f2c89c2e2f31cb4d21367890 Mon Sep 17 00:00:00 2001 From: sneakernuts <671942+sneakernuts@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:02:00 -0700 Subject: [PATCH 08/89] SRE-3494 cleanup (#6657) --- src/Core/Context/CurrentContext.cs | 25 ------------------- src/Core/Context/ICurrentContext.cs | 3 --- test/Core.Test/Context/CurrentContextTests.cs | 24 ------------------ 3 files changed, 52 deletions(-) diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 5d9b5a1759..6067c60556 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -38,10 +38,6 @@ public class CurrentContext( public virtual List Providers { get; set; } public virtual Guid? InstallationId { get; set; } public virtual Guid? OrganizationId { get; set; } - public virtual bool CloudflareWorkerProxied { get; set; } - public virtual bool IsBot { get; set; } - public virtual bool MaybeBot { get; set; } - public virtual int? BotScore { get; set; } public virtual string ClientId { get; set; } public virtual Version ClientVersion { get; set; } public virtual bool ClientVersionIsPrerelease { get; set; } @@ -70,27 +66,6 @@ public class CurrentContext( DeviceType = dType; } - if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) && - int.TryParse(cfBotScore, out var parsedBotScore)) - { - BotScore = parsedBotScore; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied)) - { - CloudflareWorkerProxied = cfWorkedProxied == "1"; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot)) - { - IsBot = cfIsBot == "1"; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot)) - { - MaybeBot = cfMaybeBot == "1"; - } - if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion)) { ClientVersion = cVersion; diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index f62a048070..d527cdd363 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -31,9 +31,6 @@ public interface ICurrentContext Guid? InstallationId { get; set; } Guid? OrganizationId { get; set; } IdentityClientType IdentityClientType { get; set; } - bool IsBot { get; set; } - bool MaybeBot { get; set; } - int? BotScore { get; set; } string ClientId { get; set; } Version ClientVersion { get; set; } bool ClientVersionIsPrerelease { get; set; } diff --git a/test/Core.Test/Context/CurrentContextTests.cs b/test/Core.Test/Context/CurrentContextTests.cs index b868d6ceaa..41a54a5b22 100644 --- a/test/Core.Test/Context/CurrentContextTests.cs +++ b/test/Core.Test/Context/CurrentContextTests.cs @@ -107,30 +107,6 @@ public class CurrentContextTests Assert.Equal(deviceType, sutProvider.Sut.DeviceType); } - [Theory, BitAutoData] - public async Task BuildAsync_HttpContext_SetsCloudflareFlags( - SutProvider sutProvider) - { - var httpContext = new DefaultHttpContext(); - var globalSettings = new Core.Settings.GlobalSettings(); - sutProvider.Sut.BotScore = null; - // Arrange - var botScore = 85; - httpContext.Request.Headers["X-Cf-Bot-Score"] = botScore.ToString(); - httpContext.Request.Headers["X-Cf-Worked-Proxied"] = "1"; - httpContext.Request.Headers["X-Cf-Is-Bot"] = "1"; - httpContext.Request.Headers["X-Cf-Maybe-Bot"] = "1"; - - // Act - await sutProvider.Sut.BuildAsync(httpContext, globalSettings); - - // Assert - Assert.True(sutProvider.Sut.CloudflareWorkerProxied); - Assert.True(sutProvider.Sut.IsBot); - Assert.True(sutProvider.Sut.MaybeBot); - Assert.Equal(botScore, sutProvider.Sut.BotScore); - } - [Theory, BitAutoData] public async Task BuildAsync_HttpContext_SetsClientVersion( SutProvider sutProvider) From 8a67aafbe5e01a738b479ebb82185fbc947866f8 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Sun, 30 Nov 2025 16:55:47 -0500 Subject: [PATCH 09/89] [PM-1632] Redirect on `SsoRequired` - return SsoOrganizationIdentifier (#6597) feat: add SSO request validation and organization identifier lookup - Implement SsoRequestValidator to validate SSO requirements - Add UserSsoOrganizationIdentifierQuery to fetch organization identifiers - Create SsoOrganizationIdentifier custom response for SSO redirects - Add feature flag (RedirectOnSsoRequired) for gradual rollout - Register validators and queries in dependency injection - Create RequestValidationConstants to reduce magic strings - Add comprehensive test coverage for validation logic - Update BaseRequestValidator to consume SsoRequestValidator --- .../IUserSsoOrganizationIdentifierQuery.cs | 23 + .../Sso/UserSsoOrganizationIdentifierQuery.cs | 38 ++ .../UserServiceCollectionExtensions.cs | 9 +- src/Core/Constants.cs | 1 + .../Constants/RequestValidationConstants.cs | 30 ++ .../RequestValidators/BaseRequestValidator.cs | 103 ++-- .../CustomTokenRequestValidator.cs | 2 + .../RequestValidators/ISsoRequestValidator.cs | 19 + .../ResourceOwnerPasswordValidator.cs | 2 + .../RequestValidators/SsoRequestValidator.cs | 126 +++++ .../WebAuthnGrantValidator.cs | 2 + .../Utilities/ServiceCollectionExtensions.cs | 1 + ...UserSsoOrganizationIdentifierQueryTests.cs | 275 ++++++++++ .../AutoFixture/RequestValidationFixtures.cs | 7 +- .../BaseRequestValidatorTests.cs | 374 +++++++++++++- .../SsoRequestValidatorTests.cs | 469 ++++++++++++++++++ .../TwoFactorAuthenticationValidatorTests.cs | 6 +- .../BaseRequestValidatorTestWrapper.cs | 11 +- 18 files changed, 1448 insertions(+), 50 deletions(-) create mode 100644 src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs create mode 100644 src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs create mode 100644 src/Identity/IdentityServer/Constants/RequestValidationConstants.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/ISsoRequestValidator.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SsoRequestValidator.cs create mode 100644 test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs create mode 100644 test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs diff --git a/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c932eb0c34 --- /dev/null +++ b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Sso; + +/// +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public interface IUserSsoOrganizationIdentifierQuery +{ + /// + /// Retrieves the SSO organization identifier for a confirmed organization user. + /// If there is more than one organization a User is associated with, we return null. If there are more than one + /// organization there is no way to know which organization the user wishes to authenticate with. + /// Owners and Admins who are not subject to the SSO required policy cannot utilize this flow, since they may have + /// multiple organizations with different SSO configurations. + /// + /// The ID of the to retrieve the SSO organization for. _Not_ an . + /// + /// The organization identifier if the user is a confirmed member of an organization with SSO configured, + /// otherwise null + /// + Task GetSsoOrganizationIdentifierAsync(Guid userId); +} diff --git a/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c0751e1f1a --- /dev/null +++ b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.Sso; + +/// +/// TODO : PM-28846 review data structures as they relate to this query +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public class UserSsoOrganizationIdentifierQuery( + IOrganizationUserRepository _organizationUserRepository, + IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery +{ + /// + public async Task GetSsoOrganizationIdentifierAsync(Guid userId) + { + // Get all confirmed organization memberships for the user + var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(userId); + + // we can only confidently return the correct SsoOrganizationIdentifier if there is exactly one Organization. + // The user must also be in the Confirmed status. + var confirmedOrgUsers = organizationUsers.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed); + if (confirmedOrgUsers.Count() != 1) + { + return null; + } + + var confirmedOrgUser = confirmedOrgUsers.Single(); + var organization = await _organizationRepository.GetByIdAsync(confirmedOrgUser.OrganizationId); + + if (organization == null) + { + return null; + } + + return organization.Identifier; + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 53bd8bdba2..7c50f7f17b 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ - - +using Bit.Core.Auth.Sso; using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); services.AddTwoFactorQueries(); + services.AddSsoQueries(); } public static void AddDeviceTrustCommands(this IServiceCollection services) @@ -69,4 +69,9 @@ public static class UserServiceCollectionExtensions { services.AddScoped(); } + + private static void AddSsoQueries(this IServiceCollection services) + { + services.AddScoped(); + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5d2cd54489..781ec8b6c1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -166,6 +166,7 @@ public static class FeatureFlagKeys public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; + public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Identity/IdentityServer/Constants/RequestValidationConstants.cs b/src/Identity/IdentityServer/Constants/RequestValidationConstants.cs new file mode 100644 index 0000000000..4787125045 --- /dev/null +++ b/src/Identity/IdentityServer/Constants/RequestValidationConstants.cs @@ -0,0 +1,30 @@ +namespace Bit.Identity.IdentityServer.RequestValidationConstants; + +public static class CustomResponseConstants +{ + public static class ResponseKeys + { + /// + /// Identifies the error model returned in the custom response when an error occurs. + /// + public static string ErrorModel => "ErrorModel"; + /// + /// This Key is used when a user is in a single organization that requires SSO authentication. The identifier + /// is used by the client to speed the redirection to the correct IdP for the user's organization. + /// + public static string SsoOrganizationIdentifier => "SsoOrganizationIdentifier"; + } +} + +public static class SsoConstants +{ + /// + /// These are messages and errors we return when SSO Validation is unsuccessful + /// + public static class RequestErrors + { + public static string SsoRequired => "sso_required"; + public static string SsoRequiredDescription => "Sso authentication is required."; + public static string SsoTwoFactorRecoveryDescription => "Two-factor recovery has been performed. SSO authentication is required."; + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 224c7a1866..fdc70b0edf 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -34,6 +34,7 @@ public abstract class BaseRequestValidator where T : class private readonly IEventService _eventService; private readonly IDeviceValidator _deviceValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; + private readonly ISsoRequestValidator _ssoRequestValidator; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; @@ -43,7 +44,7 @@ public abstract class BaseRequestValidator where T : class protected ICurrentContext CurrentContext { get; } protected IPolicyService PolicyService { get; } - protected IFeatureService FeatureService { get; } + protected IFeatureService _featureService { get; } protected ISsoConfigRepository SsoConfigRepository { get; } protected IUserService _userService { get; } protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; } @@ -56,6 +57,7 @@ public abstract class BaseRequestValidator where T : class IEventService eventService, IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, + ISsoRequestValidator ssoRequestValidator, IOrganizationUserRepository organizationUserRepository, ILogger logger, ICurrentContext currentContext, @@ -76,13 +78,14 @@ public abstract class BaseRequestValidator where T : class _eventService = eventService; _deviceValidator = deviceValidator; _twoFactorAuthenticationValidator = twoFactorAuthenticationValidator; + _ssoRequestValidator = ssoRequestValidator; _organizationUserRepository = organizationUserRepository; _logger = logger; CurrentContext = currentContext; _globalSettings = globalSettings; PolicyService = policyService; _userRepository = userRepository; - FeatureService = featureService; + _featureService = featureService; SsoConfigRepository = ssoConfigRepository; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; PolicyRequirementQuery = policyRequirementQuery; @@ -94,7 +97,7 @@ public abstract class BaseRequestValidator where T : class protected async Task ValidateAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) + if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) { var validators = DetermineValidationOrder(context, request, validatorContext); var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators); @@ -120,15 +123,29 @@ public abstract class BaseRequestValidator where T : class } // 2. Decide if this user belongs to an organization that requires SSO. - validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); - if (validatorContext.SsoRequired) + // TODO: Clean up Feature Flag: Remove this if block: PM-28281 + if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) { - SetSsoResult(context, - new Dictionary - { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return; + validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); + if (validatorContext.SsoRequired) + { + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); + return; + } + } + else + { + var ssoValid = await _ssoRequestValidator.ValidateAsync(user, request, validatorContext); + if (!ssoValid) + { + // SSO is required + SetValidationErrorResult(context, validatorContext); + return; + } } // 3. Check if 2FA is required. @@ -355,36 +372,51 @@ public abstract class BaseRequestValidator where T : class private async Task ValidateSsoAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); - if (!validatorContext.SsoRequired) + // TODO: Clean up Feature Flag: Remove this if block: PM-28281 + if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) { - return true; - } + validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); + if (!validatorContext.SsoRequired) + { + return true; + } - // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are - // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and - // review their new recovery token if desired. - // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. - // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been - // evaluated, and recovery will have been performed if requested. - // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect - // to /login. - if (validatorContext.TwoFactorRequired && - validatorContext.TwoFactorRecoveryRequested) - { - SetSsoResult(context, new Dictionary + // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are + // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and + // review their new recovery token if desired. + // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. + // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been + // evaluated, and recovery will have been performed if requested. + // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect + // to /login. + if (validatorContext.TwoFactorRequired && + validatorContext.TwoFactorRecoveryRequested) + { + SetSsoResult(context, new Dictionary { { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } }); + return false; + } + + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); return false; } - - SetSsoResult(context, - new Dictionary + else + { + var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext); + if (ssoValid) { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return false; + return true; + } + + SetValidationErrorResult(context, validatorContext); + return ssoValid; + } } /// @@ -651,6 +683,7 @@ public abstract class BaseRequestValidator where T : class /// user trying to login /// magic string identifying the grant type requested /// true if sso required; false if not required or already in process + [Obsolete("This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")] private async Task RequireSsoLoginAsync(User user, string grantType) { if (grantType == "authorization_code" || grantType == "client_credentials") @@ -661,7 +694,7 @@ public abstract class BaseRequestValidator where T : class } // Check if user belongs to any organization with an active SSO policy - var ssoRequired = FeatureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) ? (await PolicyRequirementQuery.GetAsync(user.Id)) .SsoRequired : await PolicyService.AnyPoliciesApplicableToUserAsync( @@ -703,7 +736,7 @@ public abstract class BaseRequestValidator where T : class private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType) { - if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) + if (_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) { await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress); diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 64156ea5f3..4d75da92fe 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -36,6 +36,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator logger, ICurrentContext currentContext, @@ -56,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator +/// Validates whether a user is required to authenticate via SSO based on organization policies. +/// +public interface ISsoRequestValidator +{ + /// + /// Validates the SSO requirement for a user attempting to authenticate. Sets the error state in the if SSO is required. + /// + /// The user attempting to authenticate. + /// The token request containing grant type and other authentication details. + /// The validator context to be updated with SSO requirement status and error results if applicable. + /// true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow. + Task ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context); +} diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index d69d521ef7..ea2c021f63 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -31,6 +31,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator logger, ICurrentContext currentContext, @@ -50,6 +51,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator +/// Validates whether a user is required to authenticate via SSO based on organization policies. +/// +public class SsoRequestValidator( + IPolicyService _policyService, + IFeatureService _featureService, + IUserSsoOrganizationIdentifierQuery _userSsoOrganizationIdentifierQuery, + IPolicyRequirementQuery _policyRequirementQuery) : ISsoRequestValidator +{ + /// + /// Validates the SSO requirement for a user attempting to authenticate. + /// Sets context.SsoRequired to indicate whether SSO is required. + /// If SSO is required, sets the validation error result and custom response in the context. + /// + /// The user attempting to authenticate. + /// The token request containing grant type and other authentication details. + /// The validator context to be updated with SSO requirement status and error results if applicable. + /// true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow. + public async Task ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context) + { + context.SsoRequired = await RequireSsoAuthenticationAsync(user, request.GrantType); + + if (!context.SsoRequired) + { + return true; + } + + // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are + // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and + // review their new recovery token if desired. + // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. + // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been + // evaluated, and recovery will have been performed if requested. + // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect + // to /login. + // If the feature flag RecoveryCodeSupportForSsoRequiredUsers is set to false then this code is unreachable since + // Two Factor validation occurs after SSO validation in that scenario. + if (context.TwoFactorRequired && context.TwoFactorRecoveryRequested) + { + await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription); + return false; + } + + await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoRequiredDescription); + return false; + } + + /// + /// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are + /// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement. + /// If the GrantType is authorization_code or client_credentials we know the user is trying to login + /// using the SSO flow so they are allowed to continue. + /// + /// user trying to login + /// magic string identifying the grant type requested + /// true if sso required; false if not required or already in process + private async Task RequireSsoAuthenticationAsync(User user, string grantType) + { + if (grantType == OidcConstants.GrantTypes.AuthorizationCode || + grantType == OidcConstants.GrantTypes.ClientCredentials) + { + // SSO is not required for users already using SSO to authenticate which uses the authorization_code grant type, + // or logging-in via API key which is the client_credentials grant type. + // Allow user to continue request validation + return false; + } + + // Check if user belongs to any organization with an active SSO policy + var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + ? (await _policyRequirementQuery.GetAsync(user.Id)) + .SsoRequired + : await _policyService.AnyPoliciesApplicableToUserAsync( + user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + + if (ssoRequired) + { + return true; + } + + // Default - SSO is not required + return false; + } + + /// + /// Sets the customResponse in the context with the error result for the SSO validation failure. + /// + /// The validator context to update with error details. + /// The error message to return to the client. + private async Task SetContextCustomResponseSsoErrorAsync(CustomValidatorRequestContext context, string errorMessage) + { + var ssoOrganizationIdentifier = await _userSsoOrganizationIdentifierQuery.GetSsoOrganizationIdentifierAsync(context.User.Id); + + context.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = OidcConstants.TokenErrors.InvalidGrant, + ErrorDescription = errorMessage + }; + + context.CustomResponse = new Dictionary + { + { CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(errorMessage) } + }; + + // Include organization identifier in the response if available + if (!string.IsNullOrEmpty(ssoOrganizationIdentifier)) + { + context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier] = ssoOrganizationIdentifier; + } + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 294df1c18d..e4cd60827e 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -38,6 +38,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator logger, ICurrentContext currentContext, @@ -59,6 +60,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); services.AddTransient, SendEmailOtpRequestValidator>(); diff --git a/test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs b/test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs new file mode 100644 index 0000000000..2b448ba79f --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs @@ -0,0 +1,275 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Sso; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.Sso; + +[SutProviderCustomize] +public class UserSsoOrganizationIdentifierQueryTests +{ + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasSingleConfirmedOrganization_ReturnsIdentifier( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organization.Identifier = "test-org-identifier"; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Equal("test-org-identifier", result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasNoOrganizations_ReturnsNull( + SutProvider sutProvider, + Guid userId) + { + // Arrange + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(Array.Empty()); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasMultipleConfirmedOrganizations_ReturnsNull( + SutProvider sutProvider, + Guid userId, + OrganizationUser organizationUser1, + OrganizationUser organizationUser2) + { + // Arrange + organizationUser1.UserId = userId; + organizationUser1.Status = OrganizationUserStatusType.Confirmed; + organizationUser2.UserId = userId; + organizationUser2.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser1, organizationUser2]); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByIdAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + public async Task GetSsoOrganizationIdentifierAsync_UserHasOnlyInvitedOrganization_ReturnsNull( + OrganizationUserStatusType status, + SutProvider sutProvider, + Guid userId, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.Status = status; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasMixedStatusOrganizations_OnlyOneConfirmed_ReturnsIdentifier( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser confirmedOrgUser, + OrganizationUser invitedOrgUser, + OrganizationUser revokedOrgUser) + { + // Arrange + confirmedOrgUser.UserId = userId; + confirmedOrgUser.OrganizationId = organization.Id; + confirmedOrgUser.Status = OrganizationUserStatusType.Confirmed; + + invitedOrgUser.UserId = userId; + invitedOrgUser.Status = OrganizationUserStatusType.Invited; + + revokedOrgUser.UserId = userId; + revokedOrgUser.Status = OrganizationUserStatusType.Revoked; + + organization.Identifier = "mixed-status-org"; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new[] { confirmedOrgUser, invitedOrgUser, revokedOrgUser }); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Equal("mixed-status-org", result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_OrganizationNotFound_ReturnsNull( + SutProvider sutProvider, + Guid userId, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.OrganizationId) + .Returns((Organization)null); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organizationUser.OrganizationId); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsNull_ReturnsNull( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organization.Identifier = null; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new[] { organizationUser }); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsEmpty_ReturnsEmpty( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organization.Identifier = string.Empty; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new[] { organizationUser }); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Equal(string.Empty, result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } +} diff --git a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs index 3063524a57..9dfdf723f3 100644 --- a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs +++ b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs @@ -44,14 +44,17 @@ internal class CustomValidatorRequestContextCustomization : ICustomization /// , and /// should initialize false, /// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these - /// truthy; that is the responsibility of the + /// truthy; that is the responsibility of the . + /// ValidationErrorResult and CustomResponse should also be null initially; they are hydrated during the validation process. /// public void Customize(IFixture fixture) { fixture.Customize(composer => composer .With(o => o.RememberMeRequested, false) .With(o => o.TwoFactorRecoveryRequested, false) - .With(o => o.SsoRequired, false)); + .With(o => o.SsoRequired, false) + .With(o => o.ValidationErrorResult, () => null) + .With(o => o.CustomResponse, () => null)); } } diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index e78c7d161c..214fa74ff4 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -21,6 +21,7 @@ using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.RequestValidators; using Bit.Identity.Test.Wrappers; using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -42,6 +43,7 @@ public class BaseRequestValidatorTests private readonly IEventService _eventService; private readonly IDeviceValidator _deviceValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; + private readonly ISsoRequestValidator _ssoRequestValidator; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly FakeLogger _logger; private readonly ICurrentContext _currentContext; @@ -65,6 +67,7 @@ public class BaseRequestValidatorTests _eventService = Substitute.For(); _deviceValidator = Substitute.For(); _twoFactorAuthenticationValidator = Substitute.For(); + _ssoRequestValidator = Substitute.For(); _organizationUserRepository = Substitute.For(); _logger = new FakeLogger(); _currentContext = Substitute.For(); @@ -85,6 +88,7 @@ public class BaseRequestValidatorTests _eventService, _deviceValidator, _twoFactorAuthenticationValidator, + _ssoRequestValidator, _organizationUserRepository, _logger, _currentContext, @@ -151,6 +155,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -162,9 +167,9 @@ public class BaseRequestValidatorTests // 4 -> set up device validator to fail requestContext.KnownDevice = false; - tokenRequest.GrantType = "password"; + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; _deviceValidator - .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(false)); // 5 -> not legacy user @@ -192,6 +197,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -203,12 +209,13 @@ public class BaseRequestValidatorTests // 4 -> set up device validator to pass _deviceValidator - .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); // 5 -> not legacy user _userService.IsLegacyUser(Arg.Any()) .Returns(false); + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -236,6 +243,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -262,12 +270,13 @@ public class BaseRequestValidatorTests // 4 -> set up device validator to pass _deviceValidator - .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); // 5 -> not legacy user _userService.IsLegacyUser(Arg.Any()) .Returns(false); + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -299,6 +308,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -319,10 +329,19 @@ public class BaseRequestValidatorTests // 2 -> will result to false with no extra configuration // 3 -> set two factor to be required + requestContext.User.TwoFactorProviders = "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"user@test.dev\"}}}"; _twoFactorAuthenticationValidator - .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) + .RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(true, null))); + _twoFactorAuthenticationValidator + .BuildTwoFactorResultAsync(requestContext.User, null) + .Returns(Task.FromResult(new Dictionary + { + { "TwoFactorProviders", new[] { "0", "1" } }, + { "TwoFactorProviders2", new Dictionary{{"Email", null}} } + })); + // Act await _sut.ValidateAsync(context); @@ -330,7 +349,10 @@ public class BaseRequestValidatorTests Assert.True(context.GrantResult.IsError); // Assert that the auth request was NOT consumed - await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await _authRequestRepository.DidNotReceive().ReplaceAsync(authRequest); + + // Assert that the error is for 2fa + Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription); } [Theory] @@ -420,6 +442,7 @@ public class BaseRequestValidatorTests { "TwoFactorProviders", new[] { "0", "1" } }, { "TwoFactorProviders2", new Dictionary() } }; + _twoFactorAuthenticationValidator .BuildTwoFactorResultAsync(user, null) .Returns(Task.FromResult(twoFactorResultDict)); @@ -428,6 +451,8 @@ public class BaseRequestValidatorTests await _sut.ValidateAsync(context); // Assert + Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription); + // Verify that the failed 2FA email was NOT sent for remember token expiration await _mailService.DidNotReceive() .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(), @@ -1243,6 +1268,343 @@ public class BaseRequestValidatorTests } } + /// + /// Tests that when RedirectOnSsoRequired is DISABLED, the legacy SSO validation path is used. + /// This validates the deprecated RequireSsoLoginAsync method is called and SSO requirement + /// is checked using the old PolicyService.AnyPoliciesApplicableToUserAsync approach. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_UsesLegacySsoValidation( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + + // SSO is required via legacy path + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + Assert.Equal("SSO authentication is required.", errorResponse.Message); + + // Verify legacy path was used + await _policyService.Received(1).AnyPoliciesApplicableToUserAsync( + requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + + // Verify new SsoRequestValidator was NOT called + await _ssoRequestValidator.DidNotReceive().ValidateAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED, the new ISsoRequestValidator is used + /// instead of the legacy RequireSsoLoginAsync method. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_UsesNewSsoRequestValidator( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + + // Configure SsoRequestValidator to indicate SSO is required + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); // false = SSO required + + // Set up the ValidationErrorResult that SsoRequestValidator would set + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = "sso_required", + ErrorDescription = "SSO authentication is required." + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }; + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + + // Verify new SsoRequestValidator was called + await _ssoRequestValidator.Received(1).ValidateAsync( + requestContext.User, + tokenRequest, + requestContext); + + // Verify legacy path was NOT used + await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED and SSO is NOT required, + /// authentication continues successfully through the new validation path. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_SsoNotRequired_SuccessfulLogin( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + tokenRequest.ClientId = "web"; + + // SsoRequestValidator returns true (SSO not required) + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(true)); + + // No 2FA required + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(false, null))); + + // Device validation passes + _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // User is not legacy + _userService.IsLegacyUser(Arg.Any()).Returns(false); + + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + "test-private-key", + "test-public-key" + ) + }); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.False(context.GrantResult.IsError); + await _eventService.Received(1).LogUserEventAsync(requestContext.User.Id, EventType.User_LoggedIn); + + // Verify new validator was used + await _ssoRequestValidator.Received(1).ValidateAsync( + requestContext.User, + tokenRequest, + requestContext); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED and SSO validation returns a custom response + /// (e.g., with organization identifier), that custom response is properly propagated to the result. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_PropagatesCustomResponse( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + + // SsoRequestValidator sets custom response with organization identifier + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = "sso_required", + ErrorDescription = "SSO authentication is required." + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }, + { "SsoOrganizationIdentifier", "test-org-identifier" } + }; + + var context = CreateContext(tokenRequest, requestContext, grantResult); + + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + Assert.NotNull(context.GrantResult.CustomResponse); + Assert.Contains("SsoOrganizationIdentifier", context.CustomValidatorRequestContext.CustomResponse); + Assert.Equal("test-org-identifier", context.CustomValidatorRequestContext.CustomResponse["SsoOrganizationIdentifier"]); + } + + /// + /// Tests that when RedirectOnSsoRequired is DISABLED and a user with 2FA recovery completes recovery, + /// but SSO is required, the legacy error message is returned (without the recovery-specific message). + /// + [Theory] + [BitAutoData] + public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_RecoveryWithSso_LegacyMessage( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + // Recovery code scenario + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code"; + + // 2FA with recovery + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code") + .Returns(Task.FromResult(true)); + + // SSO is required (legacy check) + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + + // Legacy behavior: recovery-specific message IS shown even without RedirectOnSsoRequired + Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message); + + // But legacy validation path was used + await _policyService.Received(1).AnyPoliciesApplicableToUserAsync( + requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED and recovery code is used for SSO-required user, + /// the SsoRequestValidator provides the recovery-specific error message. + /// + [Theory] + [BitAutoData] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_RecoveryWithSso_NewValidatorMessage( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + // Recovery code scenario + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code"; + + // 2FA with recovery + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code") + .Returns(Task.FromResult(true)); + + // SsoRequestValidator handles the recovery + SSO scenario + requestContext.TwoFactorRecoveryRequested = true; + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = "sso_required", + ErrorDescription = "Two-factor recovery has been performed. SSO authentication is required." + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } + }; + + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse["ErrorModel"]; + Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message); + + // Verify new validator was used + await _ssoRequestValidator.Received(1).ValidateAsync( + requestContext.User, + tokenRequest, + Arg.Is(ctx => ctx.TwoFactorRecoveryRequested)); + + // Verify legacy path was NOT used + await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + private BaseRequestValidationContextFake CreateContext( ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, diff --git a/test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs new file mode 100644 index 0000000000..2875b5bd37 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs @@ -0,0 +1,469 @@ +using Bit.Core; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Sso; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidationConstants; +using Bit.Identity.IdentityServer.RequestValidators; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; +using AuthFixtures = Bit.Identity.Test.AutoFixture; + +namespace Bit.Identity.Test.IdentityServer; + +[SutProviderCustomize] +public class SsoRequestValidatorTests +{ + + [Theory] + [BitAutoData(OidcConstants.GrantTypes.AuthorizationCode)] + [BitAutoData(OidcConstants.GrantTypes.ClientCredentials)] + public async void ValidateAsync_GrantTypeIgnoresSsoRequirement_ReturnsTrue( + string grantType, + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = grantType; + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + + // Should not check policies since grant type allows bypass + await sutProvider.GetDependency().DidNotReceive() + .AnyPoliciesApplicableToUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .GetAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagEnabled_ReturnsTrue( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + + // Should use the new policy requirement query when feature flag is enabled + await sutProvider.GetDependency().Received(1).GetAsync(user.Id); + await sutProvider.GetDependency().DidNotReceive() + .AnyPoliciesApplicableToUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagDisabled_ReturnsTrue( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( + user.Id, + PolicyType.RequireSso, + OrganizationUserStatusType.Confirmed) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + + // Should use the legacy policy service when feature flag is disabled + await sutProvider.GetDependency().Received(1).AnyPoliciesApplicableToUserAsync( + user.Id, + PolicyType.RequireSso, + OrganizationUserStatusType.Confirmed); + await sutProvider.GetDependency().DidNotReceive() + .GetAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagEnabled_ReturnsFalse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.ErrorModel)); + Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagDisabled_ReturnsFalse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( + user.Id, + PolicyType.RequireSso, + OrganizationUserStatusType.Confirmed) + .Returns(true); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier")); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_TwoFactorRecoveryRequested_ReturnsFalse_WithSpecialMessage( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.TwoFactorRecoveryRequested = true; + context.TwoFactorRequired = true; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", + context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier")); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_TwoFactorRequiredButNotRecovery_ReturnsFalse_WithStandardMessage( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier")); + } + + [Theory] + [BitAutoData(OidcConstants.GrantTypes.Password)] + [BitAutoData(OidcConstants.GrantTypes.RefreshToken)] + [BitAutoData(CustomGrantTypes.WebAuthn)] + public async void ValidateAsync_VariousGrantTypes_SsoRequired_ReturnsFalse( + string grantType, + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = grantType; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + Assert.NotNull(context.CustomResponse); + } + + [Theory, BitAutoData] + public async void ValidateAsync_ContextSsoRequiredUpdated_RegardlessOfInitialValue( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.SsoRequired = true; // Start with true to ensure it gets updated + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); // Should be updated to false + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_WithOrganizationIdentifier_IncludesIdentifierInResponse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + const string orgIdentifier = "test-organization"; + request.GrantType = OidcConstants.GrantTypes.Password; + context.User = user; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns(orgIdentifier); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + Assert.Equal(orgIdentifier, context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier]); + + await sutProvider.GetDependency() + .Received(1) + .GetSsoOrganizationIdentifierAsync(user.Id); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_NoOrganizationIdentifier_DoesNotIncludeIdentifierInResponse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.User = user; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.CustomResponse); + Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + + await sutProvider.GetDependency() + .Received(1) + .GetSsoOrganizationIdentifierAsync(user.Id); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_EmptyOrganizationIdentifier_DoesNotIncludeIdentifierInResponse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.User = user; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns(string.Empty); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.CustomResponse); + Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + + await sutProvider.GetDependency() + .Received(1) + .GetSsoOrganizationIdentifierAsync(user.Id); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoNotRequired_DoesNotCallOrganizationIdentifierQuery( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetSsoOrganizationIdentifierAsync(Arg.Any()); + } +} diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index 53e9a00c9f..c4cbd4b796 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -32,7 +32,7 @@ public class TwoFactorAuthenticationValidatorTests private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IDataProtectorTokenFactory _ssoEmail2faSessionTokenable; - private readonly ITwoFactorIsEnabledQuery _twoFactorenabledQuery; + private readonly ITwoFactorIsEnabledQuery _twoFactorEnabledQuery; private readonly ICurrentContext _currentContext; private readonly TwoFactorAuthenticationValidator _sut; @@ -45,7 +45,7 @@ public class TwoFactorAuthenticationValidatorTests _organizationUserRepository = Substitute.For(); _organizationRepository = Substitute.For(); _ssoEmail2faSessionTokenable = Substitute.For>(); - _twoFactorenabledQuery = Substitute.For(); + _twoFactorEnabledQuery = Substitute.For(); _currentContext = Substitute.For(); _sut = new TwoFactorAuthenticationValidator( @@ -56,7 +56,7 @@ public class TwoFactorAuthenticationValidatorTests _organizationUserRepository, _organizationRepository, _ssoEmail2faSessionTokenable, - _twoFactorenabledQuery, + _twoFactorEnabledQuery, _currentContext); } diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index ec3e791d5b..b336e4c3c1 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -54,6 +54,7 @@ IBaseRequestValidatorTestWrapper IEventService eventService, IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, + ISsoRequestValidator ssoRequestValidator, IOrganizationUserRepository organizationUserRepository, ILogger logger, ICurrentContext currentContext, @@ -73,6 +74,7 @@ IBaseRequestValidatorTestWrapper eventService, deviceValidator, twoFactorAuthenticationValidator, + ssoRequestValidator, organizationUserRepository, logger, currentContext, @@ -132,12 +134,17 @@ IBaseRequestValidatorTestWrapper protected override void SetTwoFactorResult( BaseRequestValidationContextFake context, Dictionary customResponse) - { } + { + context.GrantResult = new GrantValidationResult( + TokenRequestErrors.InvalidGrant, "Two-factor authentication required.", customResponse); + } protected override void SetValidationErrorResult( BaseRequestValidationContextFake context, CustomValidatorRequestContext requestContext) - { } + { + context.GrantResult.IsError = true; + } protected override Task ValidateContextAsync( BaseRequestValidationContextFake context, From 3df68ea36f6c64c4f91d4f1ffe3c42f0be5f19b7 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 1 Dec 2025 02:46:51 -0500 Subject: [PATCH 10/89] BRE-1355 - Fix lite naming and remove PAT (#6658) --- .github/ISSUE_TEMPLATE/bw-lite.yml | 4 +-- .github/workflows/build.yml | 45 ++++++++++++++++------------- .github/workflows/test-database.yml | 4 +-- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bw-lite.yml b/.github/ISSUE_TEMPLATE/bw-lite.yml index f46f4b3e37..cc36164e8f 100644 --- a/.github/ISSUE_TEMPLATE/bw-lite.yml +++ b/.github/ISSUE_TEMPLATE/bw-lite.yml @@ -1,4 +1,4 @@ -name: Bitwarden Lite Deployment Bug Report +name: Bitwarden lite Deployment Bug Report description: File a bug report labels: [bug, bw-lite-deploy] body: @@ -74,7 +74,7 @@ body: id: epic-label attributes: label: Issue-Link - description: Link to our pinned issue, tracking all Bitwarden Lite + description: Link to our pinned issue, tracking all Bitwarden lite value: | https://github.com/bitwarden/server/issues/2480 validations: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2174e9527..ace6dfdc5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -185,13 +185,6 @@ jobs: - name: Log in to ACR - production subscription run: az acr login -n bitwardenprod - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - ########## Generate image tag and build Docker image ########## - name: Generate Docker image tag id: tag @@ -250,8 +243,6 @@ jobs: linux/arm64 push: true tags: ${{ steps.image-tags.outputs.tags }} - secrets: | - "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" - name: Install Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' @@ -479,20 +470,27 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Trigger Bitwarden Lite build + - name: Generate GH App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + + - name: Trigger Bitwarden lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', @@ -520,20 +518,27 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + - name: Trigger k8s deploy uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 20bc67bc6b..449855ee35 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -62,7 +62,7 @@ jobs: docker compose --profile mssql --profile postgres --profile mysql up -d shell: pwsh - - name: Add MariaDB for Bitwarden Lite + - name: Add MariaDB for Bitwarden lite # Use a different port than MySQL run: | docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10 @@ -133,7 +133,7 @@ jobs: # Default Sqlite BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" - # Bitwarden Lite MariaDB + # Bitwarden lite MariaDB BW_TEST_DATABASES__4__TYPE: "MySql" BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true" run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" From 5af060fbf558d88fb3c1f97aeb9e7dcc2f7c6bbe Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 1 Dec 2025 11:46:27 +0000 Subject: [PATCH 11/89] Bumped version to 2025.12.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 3e55b8a8cc..d0998430c4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.11.1 + 2025.12.0 Bit.$(MSBuildProjectName) enable From 62cbe36ce16f1bc0467367ef8027e9b1ff00f6c4 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 1 Dec 2025 09:11:43 -0600 Subject: [PATCH 12/89] Forgot to add AutomaticUserConfirmationPolicyEventHandler to the IPolicyValidator implementation registration. (#6637) --- .../Policies/PolicyServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e89592f020..d4949be8c6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] From a5ea603817a22cb6fbb3d0b7e69e52ac722702e3 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 1 Dec 2025 10:21:44 -0500 Subject: [PATCH 13/89] [PM-24011] Create new policy sync push notification (#6594) * create new policy sync push notification * CR feedback * add tests, fix typo --- .../Implementations/SavePolicyCommand.cs | 22 +++- .../Implementations/VNextSavePolicyCommand.cs | 20 +++- src/Core/Models/PushNotification.cs | 9 +- src/Core/Platform/Push/PushType.cs | 5 +- src/Notifications/HubHelpers.cs | 17 +++ .../Policies/SavePolicyCommandTests.cs | 103 +++++++++++++++++- test/Notifications.Test/HubHelpersTest.cs | 40 +++++++ 7 files changed, 210 insertions(+), 6 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index e2bca930d1..57140317e3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Platform.Push; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; @@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand private readonly IReadOnlyDictionary _policyValidators; private readonly TimeProvider _timeProvider; private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; + private readonly IPushNotificationService _pushNotificationService; public SavePolicyCommand(IApplicationCacheService applicationCacheService, IEventService eventService, IPolicyRepository policyRepository, IEnumerable policyValidators, TimeProvider timeProvider, - IPostSavePolicySideEffect postSavePolicySideEffect) + IPostSavePolicySideEffect postSavePolicySideEffect, + IPushNotificationService pushNotificationService) { _applicationCacheService = applicationCacheService; _eventService = eventService; _policyRepository = policyRepository; _timeProvider = timeProvider; _postSavePolicySideEffect = postSavePolicySideEffect; + _pushNotificationService = pushNotificationService; var policyValidatorsDict = new Dictionary(); foreach (var policyValidator in policyValidators) @@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand await _policyRepository.UpsertAsync(policy); await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); + await PushPolicyUpdateToClients(policy.OrganizationId, policy); + return policy; } @@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); return (savedPoliciesDict, currentPolicy); } + + Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PolicyChanged, + Target = NotificationTarget.Organization, + TargetId = organizationId, + ExcludeCurrentContext = false, + Payload = new SyncPolicyPushNotification + { + Policy = policy, + OrganizationId = organizationId + } + }); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs index 5d40cb211f..38e417d085 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Platform.Push; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; @@ -15,7 +17,8 @@ public class VNextSavePolicyCommand( IPolicyRepository policyRepository, IEnumerable policyUpdateEventHandlers, TimeProvider timeProvider, - IPolicyEventHandlerFactory policyEventHandlerFactory) + IPolicyEventHandlerFactory policyEventHandlerFactory, + IPushNotificationService pushNotificationService) : IVNextSavePolicyCommand { @@ -74,7 +77,7 @@ public class VNextSavePolicyCommand( policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; await policyRepository.UpsertAsync(policy); - + await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy); return policy; } @@ -192,4 +195,17 @@ public class VNextSavePolicyCommand( var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); return savedPoliciesDict; } + + Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PolicyChanged, + Target = NotificationTarget.Organization, + TargetId = organizationId, + ExcludeCurrentContext = false, + Payload = new SyncPolicyPushNotification + { + Policy = policy, + OrganizationId = organizationId + } + }); } diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index a622b98e05..ec39c495aa 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; using Bit.Core.NotificationCenter.Enums; namespace Bit.Core.Models; @@ -103,3 +104,9 @@ public class LogOutPushNotification public Guid UserId { get; set; } public PushNotificationLogOutReason? Reason { get; set; } } + +public class SyncPolicyPushNotification +{ + public Guid OrganizationId { get; set; } + public required Policy Policy { get; set; } +} diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index 93eca86243..9a601ab0d3 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -95,5 +95,8 @@ public enum PushType : byte OrganizationBankAccountVerified = 23, [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] - ProviderBankAccountVerified = 24 + ProviderBankAccountVerified = 24, + + [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.SyncPolicyPushNotification))] + PolicyChanged = 25, } diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index b0dec8b415..bc03bb46df 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -231,9 +231,26 @@ public class HubHelpers await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; + case PushType.PolicyChanged: + await policyChangedNotificationHandler(notificationJson, cancellationToken); + break; default: _logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); break; } } + + private async Task policyChangedNotificationHandler(string notificationJson, CancellationToken cancellationToken) + { + var policyData = JsonSerializer.Deserialize>(notificationJson, _deserializerOptions); + if (policyData is null) + { + return; + } + + await _hubContext.Clients + .Group(NotificationsHub.GetOrganizationGroup(policyData.Payload.OrganizationId)) + .SendAsync(_receiveMessageMethod, policyData, cancellationToken); + + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index b1e3faf257..275466a9bd 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -6,8 +6,11 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Platform.Push; using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; @@ -95,7 +98,8 @@ public class SavePolicyCommandTests Substitute.For(), [new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()], Substitute.For(), - Substitute.For())); + Substitute.For(), + Substitute.For())); Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message); } @@ -360,6 +364,103 @@ public class SavePolicyCommandTests .ExecuteSideEffectsAsync(default!, default!, default!); } + [Theory, BitAutoData] + public async Task VNextSaveAsync_SendsPushNotification( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy currentPolicy) + { + // Arrange + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + var savePolicyModel = new SavePolicyModel(policyUpdate); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel); + + // Assert + await sutProvider.GetDependency().Received(1) + .PushAsync(Arg.Is>(p => + p.Type == PushType.PolicyChanged && + p.Target == NotificationTarget.Organization && + p.TargetId == policyUpdate.OrganizationId && + p.ExcludeCurrentContext == false && + p.Payload.OrganizationId == policyUpdate.OrganizationId && + p.Payload.Policy.Id == result.Id && + p.Payload.Policy.Type == policyUpdate.Type && + p.Payload.Policy.Enabled == policyUpdate.Enabled && + p.Payload.Policy.Data == policyUpdate.Data)); + } + + [Theory, BitAutoData] + public async Task SaveAsync_SendsPushNotification([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([]); + + var result = await sutProvider.Sut.SaveAsync(policyUpdate); + + await sutProvider.GetDependency().Received(1) + .PushAsync(Arg.Is>(p => + p.Type == PushType.PolicyChanged && + p.Target == NotificationTarget.Organization && + p.TargetId == policyUpdate.OrganizationId && + p.ExcludeCurrentContext == false && + p.Payload.OrganizationId == policyUpdate.OrganizationId && + p.Payload.Policy.Id == result.Id && + p.Payload.Policy.Type == policyUpdate.Type && + p.Payload.Policy.Enabled == policyUpdate.Enabled && + p.Payload.Policy.Data == policyUpdate.Data)); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ExistingPolicy_SendsPushNotificationWithUpdatedPolicy( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy currentPolicy) + { + var fakePolicyValidator = new FakeSingleOrgPolicyValidator(); + fakePolicyValidator.ValidateAsyncMock(policyUpdate, null).Returns(""); + var sutProvider = SutProviderFactory([fakePolicyValidator]); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + var result = await sutProvider.Sut.SaveAsync(policyUpdate); + + await sutProvider.GetDependency().Received(1) + .PushAsync(Arg.Is>(p => + p.Type == PushType.PolicyChanged && + p.Target == NotificationTarget.Organization && + p.TargetId == policyUpdate.OrganizationId && + p.ExcludeCurrentContext == false && + p.Payload.OrganizationId == policyUpdate.OrganizationId && + p.Payload.Policy.Id == result.Id && + p.Payload.Policy.Type == policyUpdate.Type && + p.Payload.Policy.Enabled == policyUpdate.Enabled && + p.Payload.Policy.Data == policyUpdate.Data)); + } + /// /// Returns a new SutProvider with the PolicyValidators registered in the Sut. /// diff --git a/test/Notifications.Test/HubHelpersTest.cs b/test/Notifications.Test/HubHelpersTest.cs index df4d3c5f85..2cd20858f3 100644 --- a/test/Notifications.Test/HubHelpersTest.cs +++ b/test/Notifications.Test/HubHelpersTest.cs @@ -225,6 +225,30 @@ public class HubHelpersTest .Group(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task SendNotificationToHubAsync_PolicyChanged_SentToOrganizationGroup( + SutProvider sutProvider, + SyncPolicyPushNotification notification, + string contextId, + CancellationToken cancellationToken) + { + var json = ToNotificationJson(notification, PushType.PolicyChanged, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + await sutProvider.GetDependency>().Clients.Received(1) + .Group($"Organization_{notification.OrganizationId}") + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && AssertSyncPolicyPushNotification(notification, objects[0], + PushType.PolicyChanged, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + private static string ToNotificationJson(object payload, PushType type, string contextId) { var notification = new PushNotificationData(type, payload, contextId); @@ -247,4 +271,20 @@ public class HubHelpersTest expected.ClientType == pushNotificationData.Payload.ClientType && expected.RevisionDate == pushNotificationData.Payload.RevisionDate; } + + private static bool AssertSyncPolicyPushNotification(SyncPolicyPushNotification expected, object? actual, + PushType type, string contextId) + { + if (actual is not PushNotificationData pushNotificationData) + { + return false; + } + + return pushNotificationData.Type == type && + pushNotificationData.ContextId == contextId && + expected.OrganizationId == pushNotificationData.Payload.OrganizationId && + expected.Policy.Id == pushNotificationData.Payload.Policy.Id && + expected.Policy.Type == pushNotificationData.Payload.Policy.Type && + expected.Policy.Enabled == pushNotificationData.Payload.Policy.Enabled; + } } From c3301ce4755846cb8e2fec381b62a2f985bb5b39 Mon Sep 17 00:00:00 2001 From: Eli Grubb Date: Mon, 1 Dec 2025 08:33:56 -0700 Subject: [PATCH 14/89] [PM-22275] Remove encryption-related feature flags (#6654) --- src/Core/Constants.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 781ec8b6c1..af5b738cd0 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -203,14 +203,11 @@ public static class FeatureFlagKeys /* 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"; - public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation"; - public const string PM17987_BlockType0 = "pm-17987-block-type-0"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; From 599fbc0efd901292006dc3951d81edd422f353bc Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 1 Dec 2025 12:31:36 -0600 Subject: [PATCH 15/89] [PM-28616] Add flag UsePhishingBlocker to dbo.Organization (#6625) * PM-28616 Add flag UsePhishingBlocker to dbo.Organization * PM-28616 updated as per comments from claude * PM-28616 updated ToLicense Method to copy the license file * PM-28616 allow phishing blocker to be imported via license files for self-hosted * PM-28616 updated PR comments - added more views to be refreshed * PM-28616 removed proeprty from constructor as it is not used anymore. We have moved to claims based properties --- .../Controllers/OrganizationsController.cs | 1 + .../Models/OrganizationEditModel.cs | 4 + .../Models/OrganizationViewModel.cs | 1 + .../Views/Shared/_OrganizationForm.cshtml | 4 + .../BaseProfileOrganizationResponseModel.cs | 2 + .../OrganizationResponseModel.cs | 2 + .../AdminConsole/Entities/Organization.cs | 6 + .../Data/IProfileOrganizationDetails.cs | 1 + .../Data/Organizations/OrganizationAbility.cs | 2 + .../OrganizationUserOrganizationDetails.cs | 1 + .../SelfHostedOrganizationDetails.cs | 1 + .../ProviderUserOrganizationDetails.cs | 1 + .../Services/OrganizationFactory.cs | 4 +- src/Core/Billing/Licenses/LicenseConstants.cs | 1 + .../OrganizationLicenseClaimsFactory.cs | 1 + .../Models/OrganizationLicense.cs | 4 +- .../Repositories/OrganizationRepository.cs | 3 +- ...izationUserOrganizationDetailsViewQuery.cs | 3 +- ...roviderUserOrganizationDetailsViewQuery.cs | 1 + .../Stored Procedures/Organization_Create.sql | 9 +- .../Organization_ReadAbilities.sql | 3 +- .../Stored Procedures/Organization_Update.sql | 6 +- src/Sql/dbo/Tables/Organization.sql | 1 + ...rganizationUserOrganizationDetailsView.sql | 3 +- src/Sql/dbo/Views/OrganizationView.sql | 3 +- ...derUserProviderOrganizationDetailsView.sql | 3 +- .../ProfileOrganizationResponseModelTests.cs | 1 + ...eProviderOrganizationResponseModelTests.cs | 1 + .../Business/OrganizationLicenseTests.cs | 3 +- .../UpdateOrganizationLicenseCommandTests.cs | 2 +- .../AdminConsole/OrganizationTestHelpers.cs | 3 +- .../OrganizationUserRepositoryTests.cs | 3 +- ...00_AddUsePhishingBlockerToOrganization.sql | 382 ++ ...-11-21_01_AddUsePhishingBlockerToViews.sql | 241 ++ ...ePhishingBlockerToOrganization.Designer.cs | 3443 ++++++++++++++++ ..._00_AddUsePhishingBlockerToOrganization.cs | 28 + .../DatabaseContextModelSnapshot.cs | 3 + ...ePhishingBlockerToOrganization.Designer.cs | 3449 +++++++++++++++++ ..._00_AddUsePhishingBlockerToOrganization.cs | 28 + .../DatabaseContextModelSnapshot.cs | 3 + ...ePhishingBlockerToOrganization.Designer.cs | 3432 ++++++++++++++++ ..._00_AddUsePhishingBlockerToOrganization.cs | 28 + .../DatabaseContextModelSnapshot.cs | 3 + 43 files changed, 11107 insertions(+), 17 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-11-21_00_AddUsePhishingBlockerToOrganization.sql create mode 100644 util/Migrator/DbScripts/2025-11-21_01_AddUsePhishingBlockerToViews.sql create mode 100644 util/MySqlMigrations/Migrations/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs create mode 100644 util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs create mode 100644 util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 0d992cb96a..2ea539f39f 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -473,6 +473,7 @@ public class OrganizationsController : Controller organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation; + organization.UsePhishingBlocker = model.UsePhishingBlocker; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 6059a003b6..4fff85e1e8 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; UseOrganizationDomains = org.UseOrganizationDomains; UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation; + UsePhishingBlocker = org.UsePhishingBlocker; _plans = plans; } @@ -160,6 +161,8 @@ public class OrganizationEditModel : OrganizationViewModel public new bool UseSecretsManager { get; set; } [Display(Name = "Risk Insights")] public new bool UseRiskInsights { get; set; } + [Display(Name = "Phishing Blocker")] + public new bool UsePhishingBlocker { get; set; } [Display(Name = "Admin Sponsored Families")] public bool UseAdminSponsoredFamilies { get; set; } [Display(Name = "Self Host")] @@ -327,6 +330,7 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.SmServiceAccounts = SmServiceAccounts; existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts; existingOrganization.UseOrganizationDomains = UseOrganizationDomains; + existingOrganization.UsePhishingBlocker = UsePhishingBlocker; return existingOrganization; } } diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 2c126ecd8e..457686be53 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -75,6 +75,7 @@ public class OrganizationViewModel public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; + public bool UsePhishingBlocker => Organization.UsePhishingBlocker; public IEnumerable OwnersDetails { get; set; } public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index cb71c0fc78..b22859ed60 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -156,6 +156,10 @@ +
+ + +
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) {
diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs index c172c45e94..f5ef468b4e 100644 --- a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation; UseSecretsManager = organizationDetails.UseSecretsManager; + UsePhishingBlocker = organizationDetails.UsePhishingBlocker; UsePasswordManager = organizationDetails.UsePasswordManager; SelfHost = organizationDetails.SelfHost; Seats = organizationDetails.Seats; @@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } public bool SelfHost { get; set; } public int? Seats { get; set; } public short? MaxCollections { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 8006a85734..89a2d4b51f 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -71,6 +71,7 @@ public class OrganizationResponseModel : ResponseModel UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UsePhishingBlocker = organization.UsePhishingBlocker; } public Guid Id { get; set; } @@ -120,6 +121,7 @@ public class OrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } } public class OrganizationSubscriptionResponseModel : OrganizationResponseModel diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 73aa162f22..338b150de6 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -134,6 +134,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable /// public bool UseAutomaticUserConfirmation { get; set; } + /// + /// If set to true, the organization has phishing protection enabled. + /// + public bool UsePhishingBlocker { get; set; } + public void SetNewId() { if (Id == default(Guid)) @@ -334,5 +339,6 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable UseOrganizationDomains = license.UseOrganizationDomains; UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation; + UsePhishingBlocker = license.UsePhishingBlocker; } } diff --git a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs index 820b65dbfd..0368678641 100644 --- a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs @@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails bool UseAdminSponsoredFamilies { get; set; } bool UseOrganizationDomains { get; set; } bool UseAutomaticUserConfirmation { get; set; } + bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index 3c02a4f50b..7c8389c103 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -29,6 +29,7 @@ public class OrganizationAbility UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UsePhishingBlocker = organization.UsePhishingBlocker; } public Guid Id { get; set; } @@ -51,4 +52,5 @@ public class OrganizationAbility public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 8d30bfc250..00b9280337 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails public bool UseAdminSponsoredFamilies { get; set; } public bool? IsAdminInitiated { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index 84ff164943..484320c271 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization Status = Status, UseRiskInsights = UseRiskInsights, UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, + UsePhishingBlocker = UsePhishingBlocker, }; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 0d48f5cfa9..dcec028dcc 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails public string? SsoExternalId { get; set; } public string? Permissions { get; set; } public string? ResetPasswordKey { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index f5df3327b1..0c64a27431 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -62,6 +62,7 @@ public static class OrganizationFactory UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies), UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation), + UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker), }; public static Organization Create( @@ -111,6 +112,7 @@ public static class OrganizationFactory UseRiskInsights = license.UseRiskInsights, UseOrganizationDomains = license.UseOrganizationDomains, UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, - UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation + UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation, + UsePhishingBlocker = license.UsePhishingBlocker, }; } diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 79ac94be62..727bcbc229 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -44,6 +44,7 @@ public static class OrganizationLicenseConstants public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies); public const string UseOrganizationDomains = nameof(UseOrganizationDomains); public const string UseAutomaticUserConfirmation = nameof(UseAutomaticUserConfirmation); + public const string UsePhishingBlocker = nameof(UsePhishingBlocker); } public static class UserLicenseConstants diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 89543a1fc0..4a4771857e 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -57,6 +57,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory p.Name) .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index ebc2bc6606..f2da58a1dd 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -113,7 +113,8 @@ public class OrganizationRepository : Repository +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("20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization")] + partial class _20251121_00_AddUsePhishingBlockerToOrganization + { + /// + 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.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + 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("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseAutomaticUserConfirmation") + .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("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePhishingBlocker") + .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("Filters") + .HasColumnType("longtext"); + + 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(300) + .HasColumnType("varchar(300)"); + + 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.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.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("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + 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.Property("Type") + .HasColumnType("int"); + + 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("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + 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("ProjectId") + .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") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + 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("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + 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("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + 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("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + 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.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (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.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + 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.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + + 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.Dirt.Models.OrganizationApplication", 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.Dirt.Models.OrganizationReport", 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.Dirt.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.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.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .OnDelete(DeleteBehavior.Cascade); + + 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.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + 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.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") + .OnDelete(DeleteBehavior.Cascade); + + 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("SecretVersions"); + + 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/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs b/util/MySqlMigrations/Migrations/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs new file mode 100644 index 0000000000..a3ca1b1aef --- /dev/null +++ b/util/MySqlMigrations/Migrations/20251121193008_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class _20251121_00_AddUsePhishingBlockerToOrganization : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UsePhishingBlocker", + table: "Organization", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UsePhishingBlocker", + table: "Organization"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 62d9d681ea..06e05d2be8 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -244,6 +244,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("UsePasswordManager") .HasColumnType("tinyint(1)"); + b.Property("UsePhishingBlocker") + .HasColumnType("tinyint(1)"); + b.Property("UsePolicies") .HasColumnType("tinyint(1)"); diff --git a/util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs b/util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs new file mode 100644 index 0000000000..6bc8fb7b04 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs @@ -0,0 +1,3449 @@ +// +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("20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization")] + partial class _20251121_00_AddUsePhishingBlockerToOrganization + { + /// + 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.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + 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("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .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("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePhishingBlocker") + .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("Filters") + .HasColumnType("text"); + + 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(300) + .HasColumnType("character varying(300)") + .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.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.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("DefaultUserCollectionEmail") + .HasColumnType("text"); + + 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.Property("Type") + .HasColumnType("integer"); + + 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("GrantedServiceAccountId") + .HasColumnType("uuid"); + + 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("ProjectId") + .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") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + 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("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + 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("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + 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("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + 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.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (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.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + 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.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + + 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.Dirt.Models.OrganizationApplication", 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.Dirt.Models.OrganizationReport", 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.Dirt.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.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.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .OnDelete(DeleteBehavior.Cascade); + + 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.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + 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.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") + .OnDelete(DeleteBehavior.Cascade); + + 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("SecretVersions"); + + 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/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs b/util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs new file mode 100644 index 0000000000..2aee8d3248 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20251121193002_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class _20251121_00_AddUsePhishingBlockerToOrganization : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UsePhishingBlocker", + table: "Organization", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UsePhishingBlocker", + table: "Organization"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index c87b6513b0..b66f08fdc9 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -246,6 +246,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("UsePasswordManager") .HasColumnType("boolean"); + b.Property("UsePhishingBlocker") + .HasColumnType("boolean"); + b.Property("UsePolicies") .HasColumnType("boolean"); diff --git a/util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs b/util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs new file mode 100644 index 0000000000..4be88115fc --- /dev/null +++ b/util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.Designer.cs @@ -0,0 +1,3432 @@ +// +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("20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization")] + partial class _20251121_00_AddUsePhishingBlockerToOrganization + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + 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("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .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("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePhishingBlocker") + .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("Filters") + .HasColumnType("TEXT"); + + 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(300) + .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.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.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.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("DefaultUserCollectionEmail") + .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.Property("Type") + .HasColumnType("INTEGER"); + + 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("GrantedServiceAccountId") + .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("ProjectId") + .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") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + 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("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .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("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + 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("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .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.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (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.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + 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.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedDate") + .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.Dirt.Models.OrganizationApplication", 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.Dirt.Models.OrganizationReport", 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.Dirt.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.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.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .OnDelete(DeleteBehavior.Cascade); + + 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.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + 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.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") + .OnDelete(DeleteBehavior.Cascade); + + 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("SecretVersions"); + + 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/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs b/util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs new file mode 100644 index 0000000000..cb9e4dffad --- /dev/null +++ b/util/SqliteMigrations/Migrations/20251121193012_2025-11-21_00_AddUsePhishingBlockerToOrganization.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class _20251121_00_AddUsePhishingBlockerToOrganization : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UsePhishingBlocker", + table: "Organization", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UsePhishingBlocker", + table: "Organization"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 17f9a067ed..63e0fd5748 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -239,6 +239,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("UsePasswordManager") .HasColumnType("INTEGER"); + b.Property("UsePhishingBlocker") + .HasColumnType("INTEGER"); + b.Property("UsePolicies") .HasColumnType("INTEGER"); From 267759db4573bb398267b5bb535d1fa1c231a420 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:19:40 -0500 Subject: [PATCH 16/89] Update token permissions to properly trigger workflows (#6662) --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ace6dfdc5d..9b457b9d56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -486,6 +486,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: self-host - name: Trigger Bitwarden lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -534,6 +536,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: devops - name: Trigger k8s deploy uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 From 02568c8e7c8c517772012e63bfa6d85aba4f6263 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:01:26 -0600 Subject: [PATCH 17/89] [PM-28100] families 2019 email (#6645) * [PM-28100] families 2019 email * pr feedback --- src/Billing/Services/IStripeFacade.cs | 6 + .../Services/Implementations/StripeFacade.cs | 8 + .../Implementations/UpcomingInvoiceHandler.cs | 46 +- .../Renewals/families-2019-renewal.mjml | 42 ++ .../Families2019RenewalMailView.cs | 16 + .../Families2019RenewalMailView.html.hbs | 584 ++++++++++++++++++ .../Families2019RenewalMailView.text.hbs | 7 + .../Services/UpcomingInvoiceHandlerTests.cs | 55 +- 8 files changed, 754 insertions(+), 10 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 90db4a4c82..f821eeed5f 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -116,4 +116,10 @@ public interface IStripeFacade TestClockGetOptions testClockGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 7b714b4a8e..bb72091bc6 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade private readonly DiscountService _discountService = new(); private readonly SetupIntentService _setupIntentService = new(); private readonly TestClockService _testClockService = new(); + private readonly CouponService _couponService = new(); public async Task GetCharge( string chargeId, @@ -143,4 +144,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => _testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken); + + public Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken); } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 0bb51ba9f2..2686ff9412 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -9,6 +9,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; +using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; @@ -284,7 +285,7 @@ public class UpcomingInvoiceHandler( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); - await SendFamiliesRenewalEmailAsync(organization, familiesPlan); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan); return true; } catch (Exception exception) @@ -546,7 +547,18 @@ public class UpcomingInvoiceHandler( private async Task SendFamiliesRenewalEmailAsync( Organization organization, - Plan familiesPlan) + Plan familiesPlan, + Plan planBeforeAlignment) + { + await (planBeforeAlignment switch + { + { Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan), + { Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan), + _ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().") + }); + } + + private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan) { var email = new Families2020RenewalMail { @@ -560,6 +572,36 @@ public class UpcomingInvoiceHandler( await mailer.SendEmail(email); } + private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan) + { + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + if (coupon == null) + { + throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found"); + } + + if (coupon.PercentOff == null) + { + throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null"); + } + + var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100; + + var email = new Families2019RenewalMail + { + ToEmails = [organization.BillingEmail], + View = new Families2019RenewalMailView + { + BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")), + BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); + } + private async Task SendPremiumRenewalEmailAsync( User user, PremiumPlan premiumPlan) diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml new file mode 100644 index 0000000000..092ae303de --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually + at {{BaseAnnualRenewalPrice}} + tax. + + + As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. + + + Questions? Contact + support@bitwarden.com + + + + + + + + + + + + + + + + diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs new file mode 100644 index 0000000000..e3aff02f5d --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs @@ -0,0 +1,16 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; + +public class Families2019RenewalMailView : BaseMailView +{ + public required string BaseMonthlyRenewalPrice { get; set; } + public required string BaseAnnualRenewalPrice { get; set; } + public required string DiscountedAnnualRenewalPrice { get; set; } + public required string DiscountAmount { get; set; } +} + +public class Families2019RenewalMail : BaseMail +{ + public override string Subject { get => "Your Bitwarden Families renewal is updating"; } +} diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs new file mode 100644 index 0000000000..227613999b --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs @@ -0,0 +1,584 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ +
Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually + at {{BaseAnnualRenewalPrice}} + tax.
+ +
+ +
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
+ +
+ +
Questions? Contact + support@bitwarden.com
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs new file mode 100644 index 0000000000..88d64f9acf --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs @@ -0,0 +1,7 @@ +Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually +at {{BaseAnnualRenewalPrice}} + tax. + +As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. +This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. + +Questions? Contact support@bitwarden.com diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index f1d8c4ba2e..483a850bd8 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; +using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; @@ -1006,8 +1007,11 @@ public class UpcomingInvoiceHandlerTests PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1033,6 +1037,8 @@ public class UpcomingInvoiceHandlerTests o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1042,10 +1048,13 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1529,8 +1538,11 @@ public class UpcomingInvoiceHandlerTests PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1556,6 +1568,8 @@ public class UpcomingInvoiceHandlerTests o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1565,10 +1579,13 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1635,8 +1652,11 @@ public class UpcomingInvoiceHandlerTests PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1662,6 +1682,8 @@ public class UpcomingInvoiceHandlerTests o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1671,10 +1693,13 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1748,8 +1773,11 @@ public class UpcomingInvoiceHandlerTests PlanType = PlanType.FamiliesAnnually2019 }; + var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount }; + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); _stripeEventUtilityService .GetIdsFromMetadata(subscription.Metadata) .Returns(new Tuple(_organizationId, null, null)); @@ -1777,6 +1805,8 @@ public class UpcomingInvoiceHandlerTests o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && o.ProrationBehavior == ProrationBehavior.None)); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + await _organizationRepository.Received(1).ReplaceAsync( Arg.Is(org => org.Id == _organizationId && @@ -1786,10 +1816,13 @@ public class UpcomingInvoiceHandlerTests org.Seats == familiesPlan.PasswordManager.BaseSeats)); await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("org@example.com") && email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); + email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1879,6 +1912,12 @@ public class UpcomingInvoiceHandlerTests org.Plan == familiesPlan.Name && org.UsersGetPremium == familiesPlan.UsersGetPremium && org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Bitwarden Families renewal is updating" && + email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")))); } [Fact] From 20efb5eb5e428f7d88c2c3372a41e3ba2f2b829f Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 1 Dec 2025 15:31:30 -0500 Subject: [PATCH 18/89] add readme (#6664) --- .../AutoConfirmUser/README.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md new file mode 100644 index 0000000000..063b2f6a5c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md @@ -0,0 +1,22 @@ +# Automatic User Confirmation + +Owned by: admin-console + +Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model +for the workflow is as follows: + +- The Api server sends an invite email to a user. +- The user accepts the invite request, which is sent back to the Api server +- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session. +- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server +- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB + +This Feature has the following security measures in place in order to achieve our security goals: + +- The single organization exemption for admins/owners is removed for this policy. + - This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users +- Emergency access is removed for all organization users +- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization) +- The organization has no members with the Provider user type. + - This will also prevent the policy and organization plan feature from being enabled + - This will prevent sending organization invites to provider users From aa3172e24f45fa640af91e787a7e7c1987b95d7c Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:37:31 -0500 Subject: [PATCH 19/89] [PM-6979] correct REST semantics (#6661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Return 200 OK with empty array for HIBP breach endpoint when no breaches found Changes the HIBP breach check endpoint to return HTTP 200 OK with an empty JSON array `[]` instead of 404 Not Found when no breaches are found. This follows proper REST API semantics where 404 should indicate the endpoint doesn't exist, not that a query returned no results. Changes: - src/Api/Dirt/Controllers/HibpController.cs: Lines 67-71 - Changed: return new NotFoundResult(); → return Content("[]", "application/json"); Backward Compatible: - Clients handle both 200 with [] (new) and 404 (old) - No breaking changes - Safe to deploy independently API Response Changes: - Before: GET /api/hibp/breach?username=safe@example.com → 404 Not Found - After: GET /api/hibp/breach?username=safe@example.com → 200 OK, Body: [] Impact: - No user-facing changes - Correct REST semantics - Industry-standard API response pattern * Address PR feedback: enhance comment and add comprehensive unit tests Addresses feedback from PR #6661: 1. Enhanced comment per @prograhamming's feedback (lines 69-71): - Added date stamp (12/1/2025) - Explained HIBP API behavior: returns 404 when no breaches found - Clarified HIBP API specification about 404 meaning - Maintained REST semantics justification 2. Created comprehensive unit tests per Claude bot's Finding 1: - New file: test/Api.Test/Dirt/HibpControllerTests.cs - 9 test cases covering all critical scenarios: * Missing API key validation * No breaches found (404 → 200 with []) - KEY TEST FOR PR CHANGE * Breaches found (200 with data) * Rate limiting with retry logic * Server error handling (500, 400) * URL encoding of special characters * Required headers validation * Self-hosted vs cloud User-Agent differences Test Coverage: - Before: 0% coverage for HibpController - After: ~90% coverage (all public methods and major paths) - Uses xUnit, NSubstitute, BitAutoData patterns - Matches existing Dirt controller test conventions Changes: - src/Api/Dirt/Controllers/HibpController.cs: Enhanced comment (+3 lines) - test/Api.Test/Dirt/HibpControllerTests.cs: New test file (327 lines, 9 tests) Addresses: - @prograhamming's comment about enhancing the code comment - Claude bot's Finding 1: Missing unit tests for HibpController Related: PM-6979 * fix test/formating errors --- src/Api/Dirt/Controllers/HibpController.cs | 5 +- test/Api.Test/Dirt/HibpControllerTests.cs | 292 +++++++++++++++++++++ 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 test/Api.Test/Dirt/HibpControllerTests.cs diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index d108fdbd4f..8060384502 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -66,7 +66,10 @@ public class HibpController : Controller } else if (response.StatusCode == HttpStatusCode.NotFound) { - return new NotFoundResult(); + /* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches, + an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could + not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */ + return Content("[]", "application/json"); } else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry) { diff --git a/test/Api.Test/Dirt/HibpControllerTests.cs b/test/Api.Test/Dirt/HibpControllerTests.cs new file mode 100644 index 0000000000..9be8d56eae --- /dev/null +++ b/test/Api.Test/Dirt/HibpControllerTests.cs @@ -0,0 +1,292 @@ +using System.Net; +using System.Reflection; +using Bit.Api.Dirt.Controllers; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Api.Test.Dirt; + +[ControllerCustomize(typeof(HibpController))] +[SutProviderCustomize] +public class HibpControllerTests : IDisposable +{ + private readonly HttpClient _originalHttpClient; + private readonly FieldInfo _httpClientField; + + public HibpControllerTests() + { + // Store original HttpClient for restoration + _httpClientField = typeof(HibpController).GetField("_httpClient", BindingFlags.Static | BindingFlags.NonPublic); + _originalHttpClient = (HttpClient)_httpClientField?.GetValue(null); + } + + public void Dispose() + { + // Restore original HttpClient after tests + _httpClientField?.SetValue(null, _originalHttpClient); + } + + [Theory, BitAutoData] + public async Task Get_WithMissingApiKey_ThrowsBadRequestException( + SutProvider sutProvider, + string username) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = null; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.Get(username)); + Assert.Equal("HaveIBeenPwned API key not set.", exception.Message); + } + + [Theory, BitAutoData] + public async Task Get_WithValidApiKeyAndNoBreaches_Returns200WithEmptyArray( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + var user = new User { Id = userId }; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + // Mock HttpClient to return 404 (no breaches found) + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.NotFound, ""); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + var result = await sutProvider.Sut.Get(username); + + // Assert + var contentResult = Assert.IsType(result); + Assert.Equal("[]", contentResult.Content); + Assert.Equal("application/json", contentResult.ContentType); + } + + [Theory, BitAutoData] + public async Task Get_WithValidApiKeyAndBreachesFound_Returns200WithBreachData( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + var breachData = "[{\"Name\":\"Adobe\",\"Title\":\"Adobe\",\"Domain\":\"adobe.com\"}]"; + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.OK, breachData); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + var result = await sutProvider.Sut.Get(username); + + // Assert + var contentResult = Assert.IsType(result); + Assert.Equal(breachData, contentResult.Content); + Assert.Equal("application/json", contentResult.ContentType); + } + + [Theory, BitAutoData] + public async Task Get_WithRateLimiting_RetriesWithDelay( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + // First response is rate limited, second is success + var requestCount = 0; + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + requestCount++; + if (requestCount == 1) + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests); + response.Headers.Add("retry-after", "1"); + return Task.FromResult(response); + } + else + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("") + }); + } + }); + + var mockHttpClient = new HttpClient(mockHandler); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + var result = await sutProvider.Sut.Get(username); + + // Assert + Assert.Equal(2, requestCount); // Verify retry happened + var contentResult = Assert.IsType(result); + Assert.Equal("[]", contentResult.Content); + } + + [Theory, BitAutoData] + public async Task Get_WithServerError_ThrowsBadRequestException( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.InternalServerError, ""); + _httpClientField.SetValue(null, mockHttpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.Get(username)); + Assert.Contains("Request failed. Status code:", exception.Message); + } + + [Theory, BitAutoData] + public async Task Get_WithBadRequest_ThrowsBadRequestException( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + var mockHttpClient = CreateMockHttpClient(HttpStatusCode.BadRequest, ""); + _httpClientField.SetValue(null, mockHttpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.Get(username)); + Assert.Contains("Request failed. Status code:", exception.Message); + } + + [Theory, BitAutoData] + public async Task Get_EncodesUsernameCorrectly( + SutProvider sutProvider, + Guid userId) + { + // Arrange + var usernameWithSpecialChars = "test+user@example.com"; + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + string capturedUrl = null; + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + capturedUrl = request.RequestUri.ToString(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("") + }); + }); + + var mockHttpClient = new HttpClient(mockHandler); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + await sutProvider.Sut.Get(usernameWithSpecialChars); + + // Assert + Assert.NotNull(capturedUrl); + // Username should be URL encoded (+ becomes %2B, @ becomes %40) + Assert.Contains("test%2Buser%40example.com", capturedUrl); + } + + [Theory, BitAutoData] + public async Task SendAsync_IncludesRequiredHeaders( + SutProvider sutProvider, + string username, + Guid userId) + { + // Arrange + sutProvider.GetDependency().HibpApiKey = "test-api-key"; + sutProvider.GetDependency().SelfHosted = false; + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(userId); + + HttpRequestMessage capturedRequest = null; + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + capturedRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("") + }); + }); + + var mockHttpClient = new HttpClient(mockHandler); + _httpClientField.SetValue(null, mockHttpClient); + + // Act + await sutProvider.Sut.Get(username); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains("hibp-api-key")); + Assert.True(capturedRequest.Headers.Contains("hibp-client-id")); + Assert.True(capturedRequest.Headers.Contains("User-Agent")); + Assert.Equal("Bitwarden", capturedRequest.Headers.GetValues("User-Agent").First()); + } + + /// + /// Helper to create a mock HttpClient that returns a specific status code and content + /// + private HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string content) + { + var mockHandler = new MockHttpMessageHandler((request, cancellationToken) => + { + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(content) + }); + }); + + return new HttpClient(mockHandler); + } +} + +/// +/// Mock HttpMessageHandler for testing HttpClient behavior +/// +public class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly Func> _sendAsync; + + public MockHttpMessageHandler(Func> sendAsync) + { + _sendAsync = sendAsync; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _sendAsync(request, cancellationToken); + } +} + From 63855cbb5a244f996819bd5d5884b7aa069ba7ce Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 1 Dec 2025 17:49:52 -0500 Subject: [PATCH 20/89] Add BlockClaimedDomainAccountCreationPolicyValidator to AddPolicyValidators (#6665) --- .../Policies/PolicyServiceCollectionExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index d4949be8c6..272fd8cee4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } From b3573c15fd8c2b176d666c27ed811e115d2c19af Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 2 Dec 2025 08:15:47 -0500 Subject: [PATCH 21/89] Validate any SQL migration scripts are the most recent (#6652) * Validate any SQL migration scripts are the most recent * Make string checks more robust * Clarify script location * Remove need given the additional validations that are immediately valuable * Allow past incorrectly-named migrations but now enforce * Centralize validation logic to PowerShell script --- .github/workflows/test-database.yml | 23 +++++ dev/verify_migrations.ps1 | 132 ++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 dev/verify_migrations.ps1 diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 449855ee35..b0d0c076a1 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -262,3 +262,26 @@ jobs: working-directory: "dev" run: docker compose down shell: pwsh + + validate-migration-naming: + name: Validate new migration naming and order + runs-on: ubuntu-22.04 + + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate new migrations for pull request + if: github.event_name == 'pull_request' + run: | + git fetch origin main:main + pwsh dev/verify_migrations.ps1 -BaseRef main + shell: pwsh + + - name: Validate new migrations for push + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1 + shell: pwsh diff --git a/dev/verify_migrations.ps1 b/dev/verify_migrations.ps1 new file mode 100644 index 0000000000..d63c34f2bd --- /dev/null +++ b/dev/verify_migrations.ps1 @@ -0,0 +1,132 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Validates that new database migration files follow naming conventions and chronological order. + +.DESCRIPTION + This script validates migration files in util/Migrator/DbScripts/ to ensure: + 1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql + 2. New migrations are chronologically ordered (filename sorts after existing migrations) + 3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5) + 4. A 2-digit sequence number is included (e.g., _00, _01) + +.PARAMETER BaseRef + The base git reference to compare against (e.g., 'main', 'HEAD~1') + +.PARAMETER CurrentRef + The current git reference (defaults to 'HEAD') + +.EXAMPLE + # For pull requests - compare against main branch + .\verify_migrations.ps1 -BaseRef main + +.EXAMPLE + # For pushes - compare against previous commit + .\verify_migrations.ps1 -BaseRef HEAD~1 +#> + +param( + [Parameter(Mandatory = $true)] + [string]$BaseRef, + + [Parameter(Mandatory = $false)] + [string]$CurrentRef = "HEAD" +) + +# Use invariant culture for consistent string comparison +[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture + +$migrationPath = "util/Migrator/DbScripts" + +# Get list of migrations from base reference +try { + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" + $baseMigrations = @() + } +} +catch { + Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" + $baseMigrations = @() +} + +# Get list of migrations from current reference +$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object + +# Find added migrations +$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } + +if ($addedMigrations.Count -eq 0) { + Write-Host "No new migration files added." + exit 0 +} + +Write-Host "New migration files detected:" +$addedMigrations | ForEach-Object { Write-Host " $_" } +Write-Host "" + +# Get the last migration from base reference +if ($baseMigrations.Count -eq 0) { + Write-Host "No previous migrations found (initial commit?). Skipping validation." + exit 0 +} + +$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) +Write-Host "Last migration in base reference: $lastBaseMigration" +Write-Host "" + +# Required format regex: YYYY-MM-DD_NN_Description.sql +$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' + +$validationFailed = $false + +foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Validate NEW migration filename format + if ($migrationName -notmatch $formatRegex) { + Write-Host "ERROR: Migration '$migrationName' does not match required format" + Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" + Write-Host " - YYYY: 4-digit year" + Write-Host " - MM: 2-digit month with leading zero (01-12)" + Write-Host " - DD: 2-digit day with leading zero (01-31)" + Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" + Write-Host "Example: 2025-01-15_00_MyMigration.sql" + $validationFailed = $true + continue + } + + # Compare migration name with last base migration (using ordinal string comparison) + if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" + $validationFailed = $true + } + else { + Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + } +} + +Write-Host "" + +if ($validationFailed) { + Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new migration files must:" + Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" + Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" + Write-Host " 4. Have a filename that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" + Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 3. Ensure the date is after $lastBaseMigration" + Write-Host "" + Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + exit 1 +} + +Write-Host "SUCCESS: All new migrations are correctly named and in chronological order" +exit 0 From 71be3865ea8360257129ff2d55e503311af96826 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:16:37 -0600 Subject: [PATCH 22/89] [PM-24558] Remove FF: `pm-21821-provider-portal-takeover` (#6613) * Remove FF: pm-21821-provider-portal-takeover * Run dotnet format --- .../SubscriptionUpdatedHandler.cs | 78 +------- src/Core/Constants.cs | 1 - .../SubscriptionUpdatedHandlerTests.cs | 169 +----------------- 3 files changed, 11 insertions(+), 237 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 81aeb460c2..07ffef064f 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,7 +1,5 @@ -using System.Globalization; -using Bit.Billing.Constants; +using Bit.Billing.Constants; using Bit.Billing.Jobs; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -134,11 +132,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } case StripeSubscriptionStatus.Active when providerId.HasValue: { - var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); - if (!providerPortalTakeover) - { - break; - } var provider = await _providerRepository.GetByIdAsync(providerId.Value); if (provider != null) { @@ -321,13 +314,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler Event parsedEvent, Subscription currentSubscription) { - var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); - - if (!providerPortalTakeover) - { - return; - } - var provider = await _providerRepository.GetByIdAsync(providerId); if (provider == null) { @@ -343,22 +329,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler { var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; - var updateIsSubscriptionGoingUnpaid = previousSubscription is - { - Status: + if (previousSubscription is + { + Status: StripeSubscriptionStatus.Trialing or StripeSubscriptionStatus.Active or StripeSubscriptionStatus.PastDue - } && currentSubscription is - { - Status: StripeSubscriptionStatus.Unpaid, - LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" - }; - - var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata( - previousSubscription, currentSubscription); - - if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata) + } && currentSubscription is + { + Status: StripeSubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" + }) { if (currentSubscription.TestClock != null) { @@ -369,14 +350,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }; - if (updateIsManualSuspensionViaMetadata) - { - subscriptionUpdateOptions.Metadata = new Dictionary - { - ["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture) - }; - } - await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions); } } @@ -399,37 +372,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } } - - private static bool CheckForManualSuspensionViaMetadata( - Subscription? previousSubscription, - Subscription currentSubscription) - { - /* - * When metadata on a subscription is updated, we'll receive an event that has: - * Previous Metadata: { newlyAddedKey: null } - * Current Metadata: { newlyAddedKey: newlyAddedValue } - * - * As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the - * 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null. - * - * If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue', - * we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update - * that does not update the metadata) the same as a manual suspension. - */ - const string key = "suspend_provider"; - - if (previousSubscription is not { Metadata: not null } || - !previousSubscription.Metadata.TryGetValue(key, out var previousValue)) - { - return false; - } - - if (previousValue == null) - { - return !string.IsNullOrEmpty( - currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null); - } - - return false; - } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index af5b738cd0..e63d087863 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -188,7 +188,6 @@ public static class FeatureFlagKeys /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; - public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure"; public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog"; diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 83ebd4aaa7..4a480f8c30 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -1,7 +1,6 @@ using Bit.Billing.Constants; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; @@ -126,79 +125,6 @@ public class SubscriptionUpdatedHandlerTests Arg.Is(t => t.Key.Name == $"cancel-trigger-{subscriptionId}")); } - [Fact] - public async Task - HandleAsync_UnpaidProviderSubscription_WithManualSuspensionViaMetadata_DisablesProviderAndSchedulesCancellation() - { - // Arrange - var providerId = Guid.NewGuid(); - var subscriptionId = "sub_test123"; - - var previousSubscription = new Subscription - { - Id = subscriptionId, - Status = StripeSubscriptionStatus.Active, - Metadata = new Dictionary - { - ["suspend_provider"] = null // This is the key part - metadata exists, but value is null - } - }; - - var currentSubscription = new Subscription - { - Id = subscriptionId, - Status = StripeSubscriptionStatus.Unpaid, - Items = new StripeList - { - Data = - [ - new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } - ] - }, - Metadata = new Dictionary - { - ["providerId"] = providerId.ToString(), - ["suspend_provider"] = "true" // Now has a value, indicating manual suspension - }, - TestClock = null - }; - - var parsedEvent = new Event - { - Id = "evt_test123", - Type = HandledStripeWebhook.SubscriptionUpdated, - Data = new EventData - { - Object = currentSubscription, - PreviousAttributes = JObject.FromObject(previousSubscription) - } - }; - - var provider = new Provider { Id = providerId, Enabled = true }; - - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true); - _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()).Returns(currentSubscription); - _stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata) - .Returns(Tuple.Create(null, null, providerId)); - _providerRepository.GetByIdAsync(providerId).Returns(provider); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - Assert.False(provider.Enabled); - await _providerService.Received(1).UpdateAsync(provider); - - // Verify that UpdateSubscription was called with both CancelAt and the new metadata - await _stripeFacade.Received(1).UpdateSubscription( - subscriptionId, - Arg.Is(options => - options.CancelAt.HasValue && - options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && - options.Metadata != null && - options.Metadata.ContainsKey("suspended_provider_via_webhook_at"))); - } - [Fact] public async Task HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation() @@ -243,7 +169,6 @@ public class SubscriptionUpdatedHandlerTests var provider = new Provider { Id = providerId, Enabled = true }; - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true); _stripeEventService.GetSubscription(parsedEvent, true, Arg.Any>()).Returns(currentSubscription); _stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata) .Returns(Tuple.Create(null, null, providerId)); @@ -256,13 +181,12 @@ public class SubscriptionUpdatedHandlerTests Assert.False(provider.Enabled); await _providerService.Received(1).UpdateAsync(provider); - // Verify that UpdateSubscription was called with CancelAt but WITHOUT suspension metadata + // Verify that UpdateSubscription was called with CancelAt await _stripeFacade.Received(1).UpdateSubscription( subscriptionId, Arg.Is(options => options.CancelAt.HasValue && - options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) && - (options.Metadata == null || !options.Metadata.ContainsKey("suspended_provider_via_webhook_at")))); + options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1))); } [Fact] @@ -306,9 +230,6 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(null, null, providerId)); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); - _providerRepository.GetByIdAsync(providerId) .Returns(provider); @@ -353,9 +274,6 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(null, null, providerId)); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); - _providerRepository.GetByIdAsync(providerId) .Returns(provider); @@ -401,9 +319,6 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(null, null, providerId)); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); - _providerRepository.GetByIdAsync(providerId) .Returns(provider); @@ -416,48 +331,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any(), Arg.Any()); } - [Fact] - public async Task HandleAsync_UnpaidProviderSubscription_WhenFeatureFlagDisabled_DoesNothing() - { - // Arrange - var providerId = Guid.NewGuid(); - var subscriptionId = "sub_123"; - var currentPeriodEnd = DateTime.UtcNow.AddDays(30); - - var subscription = new Subscription - { - Id = subscriptionId, - Status = StripeSubscriptionStatus.Unpaid, - Items = new StripeList - { - Data = - [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } - ] - }, - Metadata = new Dictionary { { "providerId", providerId.ToString() } }, - LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } - }; - - var parsedEvent = new Event { Data = new EventData() }; - - _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) - .Returns(subscription); - - _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) - .Returns(Tuple.Create(null, null, providerId)); - - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(false); - - // Act - await _sut.HandleAsync(parsedEvent); - - // Assert - await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any()); - await _providerService.DidNotReceive().UpdateAsync(Arg.Any()); - } - [Fact] public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_DoesNothing() { @@ -489,9 +362,6 @@ public class SubscriptionUpdatedHandlerTests _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) .Returns(Tuple.Create(null, null, providerId)); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); - _providerRepository.GetByIdAsync(providerId) .Returns((Provider)null); @@ -777,8 +647,6 @@ public class SubscriptionUpdatedHandlerTests _stripeFacade .UpdateSubscription(Arg.Any(), Arg.Any()) .Returns(newSubscription); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -800,9 +668,6 @@ public class SubscriptionUpdatedHandlerTests .Received(1) .UpdateSubscription(newSubscription.Id, Arg.Is(options => options.CancelAtPeriodEnd == false)); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } [Fact] @@ -823,8 +688,6 @@ public class SubscriptionUpdatedHandlerTests _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -843,9 +706,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } [Fact] @@ -866,8 +726,6 @@ public class SubscriptionUpdatedHandlerTests _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -886,9 +744,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } [Fact] @@ -909,8 +764,6 @@ public class SubscriptionUpdatedHandlerTests _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -929,9 +782,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } [Fact] @@ -953,8 +803,6 @@ public class SubscriptionUpdatedHandlerTests _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -975,9 +823,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade .DidNotReceiveWithAnyArgs() .UpdateSubscription(Arg.Any()); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } [Fact] @@ -997,8 +842,6 @@ public class SubscriptionUpdatedHandlerTests _providerRepository .GetByIdAsync(Arg.Any()) .ReturnsNull(); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -1019,9 +862,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade .DidNotReceive() .UpdateSubscription(Arg.Any()); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } [Fact] @@ -1040,8 +880,6 @@ public class SubscriptionUpdatedHandlerTests _providerRepository .GetByIdAsync(Arg.Any()) .Returns(provider); - _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover) - .Returns(true); // Act await _sut.HandleAsync(parsedEvent); @@ -1062,9 +900,6 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade .DidNotReceive() .UpdateSubscription(Arg.Any()); - _featureService - .Received(1) - .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent) From 5b8b394982c2427974026a35403d8ecfdb6f0d80 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:43:22 -0600 Subject: [PATCH 23/89] allow for archived ciphers to be shared into an organization (#6626) --- .../Vault/Controllers/CiphersController.cs | 15 --- .../Services/Implementations/CipherService.cs | 5 - .../Controllers/CiphersControllerTests.cs | 112 ------------------ 3 files changed, 132 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index c200810156..8c5df96262 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -757,11 +757,6 @@ public class CiphersController : Controller } } - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move an archived item to an organization."); - } - ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); @@ -1271,11 +1266,6 @@ public class CiphersController : Controller _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } - - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move archived items to an organization."); - } } var shareCiphers = new List<(CipherDetails, DateTime?)>(); @@ -1288,11 +1278,6 @@ public class CiphersController : Controller ValidateClientVersionForFido2CredentialSupport(existingCipher); - if (existingCipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move archived items to an organization."); - } - shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate)); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index cbf4ec81e3..2085345b16 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -990,11 +990,6 @@ public class CipherService : ICipherService throw new BadRequestException("One or more ciphers do not belong to you."); } - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cipher cannot be shared with organization because it is archived."); - } - var attachments = cipher.GetAttachments(); var hasAttachments = attachments?.Any() ?? false; var org = await _organizationRepository.GetByIdAsync(organizationId); diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 9f54cdbea5..416b92f841 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -1790,118 +1790,6 @@ public class CiphersControllerTests ); } - [Theory, BitAutoData] - public async Task PutShareMany_ArchivedCipher_ThrowsBadRequestException( - Guid organizationId, - Guid userId, - CipherWithIdRequestModel request, - SutProvider sutProvider) - { - request.EncryptedFor = userId; - request.OrganizationId = organizationId.ToString(); - request.ArchivedDate = DateTime.UtcNow; - var model = new CipherBulkShareRequestModel - { - Ciphers = [request], - CollectionIds = [Guid.NewGuid().ToString()] - }; - - sutProvider.GetDependency() - .OrganizationUser(organizationId) - .Returns(Task.FromResult(true)); - sutProvider.GetDependency() - .GetProperUserId(default) - .ReturnsForAnyArgs(userId); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PutShareMany(model) - ); - - Assert.Equal("Cannot move archived items to an organization.", exception.Message); - } - - [Theory, BitAutoData] - public async Task PutShareMany_ExistingCipherArchived_ThrowsBadRequestException( - Guid organizationId, - Guid userId, - CipherWithIdRequestModel request, - SutProvider sutProvider) - { - // Request model does not have ArchivedDate (only the existing cipher does) - request.EncryptedFor = userId; - request.OrganizationId = organizationId.ToString(); - request.ArchivedDate = null; - - var model = new CipherBulkShareRequestModel - { - Ciphers = [request], - CollectionIds = [Guid.NewGuid().ToString()] - }; - - // The existing cipher from the repository IS archived - var existingCipher = new CipherDetails - { - Id = request.Id!.Value, - UserId = userId, - Type = CipherType.Login, - Data = JsonSerializer.Serialize(new CipherLoginData()), - ArchivedDate = DateTime.UtcNow - }; - - sutProvider.GetDependency() - .OrganizationUser(organizationId) - .Returns(Task.FromResult(true)); - sutProvider.GetDependency() - .GetProperUserId(default) - .ReturnsForAnyArgs(userId); - sutProvider.GetDependency() - .GetManyByUserIdAsync(userId, withOrganizations: false) - .Returns(Task.FromResult((ICollection)[existingCipher])); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PutShareMany(model) - ); - - Assert.Equal("Cannot move archived items to an organization.", exception.Message); - } - - [Theory, BitAutoData] - public async Task PutShare_ArchivedCipher_ThrowsBadRequestException( - Guid cipherId, - Guid organizationId, - User user, - CipherShareRequestModel model, - SutProvider sutProvider) - { - model.Cipher.OrganizationId = organizationId.ToString(); - model.Cipher.EncryptedFor = user.Id; - - var cipher = new Cipher - { - Id = cipherId, - UserId = user.Id, - ArchivedDate = DateTime.UtcNow.AddDays(-1), - Type = CipherType.Login, - Data = JsonSerializer.Serialize(new CipherLoginData()) - }; - - sutProvider.GetDependency() - .GetUserByPrincipalAsync(Arg.Any()) - .Returns(user); - sutProvider.GetDependency() - .GetByIdAsync(cipherId) - .Returns(cipher); - sutProvider.GetDependency() - .OrganizationUser(organizationId) - .Returns(Task.FromResult(true)); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.PutShare(cipherId, model) - ); - - Assert.Equal("Cannot move an archived item to an organization.", exception.Message); - } - [Theory, BitAutoData] public async Task PostPurge_WhenUserNotFound_ThrowsUnauthorizedAccessException( SecretVerificationRequestModel model, From de5a81bdc4beea752de72539627521d840dd1976 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 2 Dec 2025 19:54:40 +0100 Subject: [PATCH 24/89] Move request models to core (#6667) * Move request models to core * Fix build * Fix * Undo changes --- .../Models/Requests/RotateAccountKeysAndDataRequestModel.cs | 1 + .../Models/Api/Request}/AccountKeysRequestModel.cs | 5 ++--- .../Api/Request}/PublicKeyEncryptionKeyPairRequestModel.cs | 2 +- .../Models/Api/Request}/SignatureKeyPairRequestModel.cs | 2 +- .../Controllers/AccountsKeyManagementControllerTests.cs | 1 + .../Models/Request/SignatureKeyPairRequestModel.cs | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) rename src/{Api/KeyManagement/Models/Requests => Core/KeyManagement/Models/Api/Request}/AccountKeysRequestModel.cs (92%) rename src/{Api/KeyManagement/Models/Requests => Core/KeyManagement/Models/Api/Request}/PublicKeyEncryptionKeyPairRequestModel.cs (91%) rename src/{Api/KeyManagement/Models/Requests => Core/KeyManagement/Models/Api/Request}/SignatureKeyPairRequestModel.cs (93%) diff --git a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs index 02780b015a..3510be9546 100644 --- a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.KeyManagement.Models.Requests; diff --git a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs similarity index 92% rename from src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs index b64e826911..bdf538e6d8 100644 --- a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs @@ -1,8 +1,7 @@ -using Bit.Core.KeyManagement.Models.Api.Request; -using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class AccountKeysRequestModel { diff --git a/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs similarity index 91% rename from src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs index 24c1e6a946..f9b009f7e2 100644 --- a/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs @@ -1,7 +1,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class PublicKeyEncryptionKeyPairRequestModel { diff --git a/src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs similarity index 93% rename from src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs index 3cdb4f53f1..a569bc70ab 100644 --- a/src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs @@ -1,7 +1,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class SignatureKeyPairRequestModel { diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 2e41dd79a0..b0afcd9144 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -14,6 +14,7 @@ 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.Api.Request; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; diff --git a/test/Api.Test/KeyManagement/Models/Request/SignatureKeyPairRequestModel.cs b/test/Api.Test/KeyManagement/Models/Request/SignatureKeyPairRequestModel.cs index 704371eebd..e1e97efce2 100644 --- a/test/Api.Test/KeyManagement/Models/Request/SignatureKeyPairRequestModel.cs +++ b/test/Api.Test/KeyManagement/Models/Request/SignatureKeyPairRequestModel.cs @@ -1,6 +1,6 @@ #nullable enable -using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.KeyManagement.Models.Api.Request; using Xunit; namespace Bit.Api.Test.KeyManagement.Models.Request; From 89a2eab32aca3fadac321ffc0f2897c268a51451 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:38:28 -0600 Subject: [PATCH 25/89] [PM-23717] premium renewal email (#6672) * [PM-23717] premium renewal email * pr feedback * pr feedback --- .../Implementations/UpcomingInvoiceHandler.cs | 24 +- .../Billing/Renewals/premium-renewal.mjml | 41 ++ .../Renewal/Premium/PremiumRenewalMailView.cs | 15 + .../Premium/PremiumRenewalMailView.html.hbs | 583 ++++++++++++++++++ .../Premium/PremiumRenewalMailView.text.hbs | 6 + .../Services/UpcomingInvoiceHandlerTests.cs | 537 +++++++++++++++- 6 files changed, 1198 insertions(+), 8 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml create mode 100644 src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs create mode 100644 src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 2686ff9412..004828dc48 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Entities; using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; +using Bit.Core.Models.Mail.Billing.Renewal.Premium; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -606,14 +607,27 @@ public class UpcomingInvoiceHandler( User user, PremiumPlan premiumPlan) { - /* TODO: Replace with proper premium renewal email template once finalized. - Using Families2020RenewalMail as a temporary stop-gap. */ - var email = new Families2020RenewalMail + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount); + if (coupon == null) + { + throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"); + } + + if (coupon.PercentOff == null) + { + throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"); + } + + var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; + + var email = new PremiumRenewalMail { ToEmails = [user.Email], - View = new Families2020RenewalMailView + View = new PremiumRenewalMailView { - MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) + BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US")) } }; diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml new file mode 100644 index 0000000000..a460442a7c --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. + + + As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. + + + Questions? Contact + support@bitwarden.com + + + + + + + + + + + + + + + + diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs new file mode 100644 index 0000000000..e231a44467 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs @@ -0,0 +1,15 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Billing.Renewal.Premium; + +public class PremiumRenewalMailView : BaseMailView +{ + public required string BaseMonthlyRenewalPrice { get; set; } + public required string DiscountedMonthlyRenewalPrice { get; set; } + public required string DiscountAmount { get; set; } +} + +public class PremiumRenewalMail : BaseMail +{ + public override string Subject { get => "Your Bitwarden Premium renewal is updating"; } +} diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs new file mode 100644 index 0000000000..a6b2fda0f7 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs @@ -0,0 +1,583 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ +
Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
+ +
+ +
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
+ +
+ +
Questions? Contact + support@bitwarden.com
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs new file mode 100644 index 0000000000..41300d0f96 --- /dev/null +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs @@ -0,0 +1,6 @@ +Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. + +As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. +This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. + +Questions? Contact support@bitwarden.com diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 483a850bd8..3b133c7d37 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Billing.Pricing.Premium; using Bit.Core.Entities; using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; +using Bit.Core.Models.Mail.Billing.Renewal.Premium; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -253,6 +254,9 @@ public class UpcomingInvoiceHandlerTests .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) .Returns(true); + var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount }; + + _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); // Act await _sut.HandleAsync(parsedEvent); @@ -260,6 +264,7 @@ public class UpcomingInvoiceHandlerTests // Assert await _userRepository.Received(1).GetByIdAsync(_userId); await _pricingClient.Received(1).GetAvailablePremiumPlan(); + await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount); await _stripeFacade.Received(1).UpdateSubscription( Arg.Is("sub_123"), Arg.Is(o => @@ -269,11 +274,15 @@ public class UpcomingInvoiceHandlerTests o.ProrationBehavior == "none")); // Verify the updated invoice email was sent with correct price + var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; await _mailer.Received(1).SendEmail( - Arg.Is(email => + Arg.Is(email => email.ToEmails.Contains("user@example.com") && - email.Subject == "Your Bitwarden Families renewal is updating" && - email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")))); + email.Subject == "Your Bitwarden Premium renewal is updating" && + email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && + email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == $"{coupon.PercentOff}%" + )); } [Fact] @@ -1474,6 +1483,200 @@ public class UpcomingInvoiceHandlerTests await _mailer.DidNotReceive().SendEmail(Arg.Any()); } + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = [new() { Description = "Test Item" }] + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - Exception is caught, error is logged, and traditional email is sent + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && + o.ToString().Contains(parsedEvent.Type) && + o.ToString().Contains(parsedEvent.Id)), + Arg.Is(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")), + Arg.Any>()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = [new() { Description = "Test Item" }] + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = + [ + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + ] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = [subscription] }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + var coupon = new Coupon + { + Id = CouponIDs.Milestone3SubscriptionDiscount, + PercentOff = null + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + _stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - Exception is caught, error is logged, and traditional email is sent + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && + o.ToString().Contains(parsedEvent.Type) && + o.ToString().Contains(parsedEvent.Id)), + Arg.Is(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")), + Arg.Any>()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + [Fact] public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem() { @@ -1996,4 +2199,332 @@ public class UpcomingInvoiceHandlerTests await _organizationRepository.DidNotReceive().ReplaceAsync( Arg.Is(org => org.PlanType == PlanType.FamiliesAnnually)); } + + #region Premium Renewal Email Tests + + [Fact] + public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = [new() { Description = "Test Item" }] + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - Exception is caught, error is logged, and traditional email is sent + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") && + o.ToString().Contains(parsedEvent.Id)), + Arg.Is(e => e is InvalidOperationException + && e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"), + Arg.Any>()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = [new() { Description = "Test Item" }] + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + var coupon = new Coupon + { + Id = CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = null + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - Exception is caught, error is logged, and traditional email is sent + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") && + o.ToString().Contains(parsedEvent.Id)), + Arg.Is(e => e is InvalidOperationException + && e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"), + Arg.Any>()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = [new() { Description = "Test Item" }] + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + var coupon = new Coupon + { + Id = CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 30 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("user@example.com") && + email.Subject == "Your Bitwarden Premium renewal is updating" && + email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && + email.View.DiscountAmount == "30%" && + email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US")) + )); + + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = [new() { Description = "Test Item" }] + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount) + .ThrowsAsync(new StripeException("Stripe API error")); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - Exception is caught, error is logged, and traditional email is sent + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") && + o.ToString().Contains(parsedEvent.Id)), + Arg.Is(e => e is StripeException), + Arg.Any>()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + #endregion } From ee26a701e9cbaa65ea5c15f407452fe8d3b0f851 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:20:56 +1000 Subject: [PATCH 26/89] [BEEEP] [PM-28808] Fix invalid identity URL in Swagger (#6653) - in generated JSON (used in help center), only show cloud options (with corrected identity URL) - in self-host and dev, only show local option --- dev/generate_openapi_files.ps1 | 4 +- src/Api/Startup.cs | 48 ++++++++++++++- .../Utilities/ServiceCollectionExtensions.cs | 51 +++++++--------- .../Utilities/ServiceCollectionExtensions.cs | 59 +++++++++++++++++++ 4 files changed, 126 insertions(+), 36 deletions(-) diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 index 9eca7dc734..011319b3a3 100644 --- a/dev/generate_openapi_files.ps1 +++ b/dev/generate_openapi_files.ps1 @@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) { # Api internal & public Set-Location "../../src/Api" dotnet build -dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" +dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" +dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 8ecdd148d3..85fef9cd87 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -216,7 +216,7 @@ public class Startup config.Conventions.Add(new PublicApiControllersModelConvention()); }); - services.AddSwagger(globalSettings, Environment); + services.AddSwaggerGen(globalSettings, Environment); Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted); services.AddHostedService(); @@ -292,17 +292,59 @@ public class Startup }); // Add Swagger + // Note that the swagger.json generation is configured in the call to AddSwaggerGen above. if (Environment.IsDevelopment() || globalSettings.SelfHosted) { + // adds the middleware to serve the swagger.json while the server is running app.UseSwagger(config => { config.RouteTemplate = "specs/{documentName}/swagger.json"; + + // Remove all Bitwarden cloud servers and only register the local server config.PreSerializeFilters.Add((swaggerDoc, httpReq) => - swaggerDoc.Servers = new List + { + swaggerDoc.Servers.Clear(); + swaggerDoc.Servers.Add(new OpenApiServer { - new OpenApiServer { Url = globalSettings.BaseServiceUri.Api } + Url = globalSettings.BaseServiceUri.Api, }); + + swaggerDoc.Components.SecuritySchemes.Clear(); + swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + ClientCredentials = new OpenApiOAuthFlow + { + TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"), + Scopes = new Dictionary + { + { ApiScopes.ApiOrganization, "Organization APIs" } + } + } + } + }); + + swaggerDoc.SecurityRequirements.Clear(); + swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "oauth2-client-credentials" + } + }, + [ApiScopes.ApiOrganization] + } + }); + }); }); + + // adds the middleware to display the web UI app.UseSwaggerUI(config => { config.DocumentTitle = "Bitwarden API Documentation"; diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 6af688f548..c90fc82d56 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.Repositories; @@ -10,6 +9,7 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.SecurityTasks; using Bit.SharedWeb.Health; using Bit.SharedWeb.Swagger; +using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.OpenApi.Models; @@ -17,7 +17,10 @@ namespace Bit.Api.Utilities; public static class ServiceCollectionExtensions { - public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) + /// + /// Configures the generation of swagger.json OpenAPI spec. + /// + public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) { services.AddSwaggerGen(config => { @@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions organizations tools for managing members, collections, groups, event logs, and policies. If you are looking for the Vault Management API, refer instead to [this document](https://bitwarden.com/help/vault-management-api/). + + **Note:** your authorization must match the server you have selected. """, License = new OpenApiLicense { @@ -46,36 +51,20 @@ public static class ServiceCollectionExtensions config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" }); - config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows - { - ClientCredentials = new OpenApiOAuthFlow - { - TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"), - Scopes = new Dictionary - { - { ApiScopes.ApiOrganization, "Organization APIs" }, - }, - } - }, - }); + // Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact + // used for our help center. These are overwritten with the local server when running in self-hosted + // or dev mode (see Api Startup.cs). + config.AddSwaggerServerWithSecurity( + serverId: "US_server", + serverUrl: "https://api.bitwarden.com", + identityTokenUrl: "https://identity.bitwarden.com/connect/token", + serverDescription: "US server"); - config.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "oauth2-client-credentials" - }, - }, - new[] { ApiScopes.ApiOrganization } - } - }); + config.AddSwaggerServerWithSecurity( + serverId: "EU_server", + serverUrl: "https://api.bitwarden.eu", + identityTokenUrl: "https://identity.bitwarden.eu/connect/token", + serverDescription: "EU server"); config.DescribeAllParametersInCamelCase(); // config.UseReferencedDefinitionsForEnums(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index ad2cc0e8fa..79f46ecb74 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -85,7 +85,9 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; using StackExchange.Redis; +using Swashbuckle.AspNetCore.SwaggerGen; using ZiggyCreatures.Caching.Fusion; using NoopRepos = Bit.Core.Repositories.Noop; using Role = Bit.Core.Entities.Role; @@ -1067,4 +1069,61 @@ public static class ServiceCollectionExtensions CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName); } + + /// + /// Adds a server with its corresponding OAuth2 client credentials security definition and requirement. + /// + /// The SwaggerGen configuration + /// Unique identifier for this server (e.g., "us-server", "eu-server") + /// The API server URL + /// The identity server token URL + /// Human-readable description for the server + public static void AddSwaggerServerWithSecurity( + this SwaggerGenOptions config, + string serverId, + string serverUrl, + string identityTokenUrl, + string serverDescription) + { + // Add server + config.AddServer(new OpenApiServer + { + Url = serverUrl, + Description = serverDescription + }); + + // Add security definition + config.AddSecurityDefinition(serverId, new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Description = $"**Use this option if you've selected the {serverDescription}**", + Flows = new OpenApiOAuthFlows + { + ClientCredentials = new OpenApiOAuthFlow + { + TokenUrl = new Uri(identityTokenUrl), + Scopes = new Dictionary + { + { ApiScopes.ApiOrganization, $"Organization APIs ({serverDescription})" }, + }, + } + }, + }); + + // Add security requirement + config.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = serverId + }, + }, + [ApiScopes.ApiOrganization] + } + }); + } } From 28e9c24f332a4cb6db6c71401c89719faf6ed403 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Tue, 2 Dec 2025 23:23:58 -0600 Subject: [PATCH 27/89] [PM-25584] [PM-25585] Remove feature flag - recover provider accounts (#6673) * chore: remove ff from OrganizationUsersController, refs PM-25584 * chore: update tests with reference to ff, refs PM-25584 * chore: remove ff definition, refs PM-25585 * chore: dotnet format, refs PM-25584 --- .../OrganizationUsersController.cs | 35 +--------- src/Core/Constants.cs | 1 - ...ionUsersControllerPutResetPasswordTests.cs | 9 --- .../OrganizationUsersControllerTests.cs | 64 ++----------------- 4 files changed, 6 insertions(+), 103 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 155b60ce5b..55b9caa550 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -483,43 +483,10 @@ public class OrganizationUsersController : BaseAdminConsoleController } } +#nullable enable [HttpPut("{id}/reset-password")] [Authorize] public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) - { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)) - { - // TODO: remove legacy implementation after feature flag is enabled. - return await PutResetPasswordNew(orgId, id, model); - } - - // Get the users role, since provider users aren't a member of the organization we use the owner check - var orgUserType = await _currentContext.OrganizationOwner(orgId) - ? OrganizationUserType.Owner - : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; - if (orgUserType == null) - { - return TypedResults.NotFound(); - } - - var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); - if (result.Succeeded) - { - return TypedResults.Ok(); - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - await Task.Delay(2000); - return TypedResults.BadRequest(ModelState); - } - -#nullable enable - // TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed. - private async Task PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) { var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id); if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e63d087863..0d3dd37df4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -140,7 +140,6 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; - public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs index 7b707fa335..38e3cac863 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs @@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums; @@ -14,8 +13,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Repositories; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; @@ -32,12 +29,6 @@ public class OrganizationUsersControllerPutResetPasswordTests : IClassFixture(featureService => - { - featureService - .IsEnabled(FeatureFlagKeys.AccountRecoveryCommand) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index ae14001223..cb03844aa2 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -452,60 +452,10 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagDisabled_CallsLegacyPath( + public async Task PutResetPassword_WhenOrganizationUserNotFound_ReturnsNotFound( Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, SutProvider sutProvider) { - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false); - sutProvider.GetDependency().OrganizationOwner(orgId).Returns(true); - sutProvider.GetDependency().AdminResetPasswordAsync(Arg.Any(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key) - .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); - - var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); - - Assert.IsType(result); - await sutProvider.GetDependency().Received(1) - .AdminResetPasswordAsync(OrganizationUserType.Owner, orgId, orgUserId, model.NewMasterPasswordHash, model.Key); - } - - [Theory] - [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull_ReturnsNotFound( - Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, - SutProvider sutProvider) - { - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false); - sutProvider.GetDependency().OrganizationOwner(orgId).Returns(false); - sutProvider.GetDependency().Organizations.Returns(new List()); - - var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); - - Assert.IsType(result); - } - - [Theory] - [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagDisabled_WhenAdminResetPasswordFails_ReturnsBadRequest( - Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, - SutProvider sutProvider) - { - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false); - sutProvider.GetDependency().OrganizationOwner(orgId).Returns(true); - sutProvider.GetDependency().AdminResetPasswordAsync(Arg.Any(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key) - .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error 1" })); - - var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); - - Assert.IsType>(result); - } - - [Theory] - [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNotFound_ReturnsNotFound( - Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, - SutProvider sutProvider) - { - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns((OrganizationUser)null); var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); @@ -515,12 +465,11 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMismatch_ReturnsNotFound( + public async Task PutResetPassword_WhenOrganizationIdMismatch_ReturnsNotFound( Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, SutProvider sutProvider) { organizationUser.OrganizationId = Guid.NewGuid(); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); @@ -530,12 +479,11 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagEnabled_WhenAuthorizationFails_ReturnsBadRequest( + public async Task PutResetPassword_WhenAuthorizationFails_ReturnsBadRequest( Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, SutProvider sutProvider) { organizationUser.OrganizationId = orgId; - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); sutProvider.GetDependency() .AuthorizeAsync( @@ -551,12 +499,11 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountSucceeds_ReturnsOk( + public async Task PutResetPassword_WhenRecoverAccountSucceeds_ReturnsOk( Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, SutProvider sutProvider) { organizationUser.OrganizationId = orgId; - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); sutProvider.GetDependency() .AuthorizeAsync( @@ -577,12 +524,11 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFails_ReturnsBadRequest( + public async Task PutResetPassword_WhenRecoverAccountFails_ReturnsBadRequest( Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, SutProvider sutProvider) { organizationUser.OrganizationId = orgId; - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); sutProvider.GetDependency() .AuthorizeAsync( From 1566a6d587b8c5f9d307f7f2f8d2eb8499877825 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Wed, 3 Dec 2025 10:52:09 -0500 Subject: [PATCH 28/89] [PM-28871] Default startIndex and count values on SCIM groups list API (#6648) * default startindex and count values on SCIM groups list api * convert params to a model, like users * review feedback * fix file name to be plural * added integration test --- .../Scim/Controllers/v2/GroupsController.cs | 10 +++--- .../src/Scim/Groups/GetGroupsListQuery.cs | 15 ++++++--- .../Groups/Interfaces/IGetGroupsListQuery.cs | 3 +- .../Scim/Models/GetGroupsQueryParamModel.cs | 14 ++++++++ ...ramModel.cs => GetUsersQueryParamModel.cs} | 2 ++ .../src/Scim/Users/GetUsersListQuery.cs | 1 + .../Users/Interfaces/IGetUsersListQuery.cs | 1 + .../Controllers/v2/GroupsControllerTests.cs | 32 +++++++++++++++++++ .../Groups/GetGroupsListQueryTests.cs | 11 ++++--- .../Scim.Test/Users/GetUsersListQueryTests.cs | 1 + 10 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs rename bitwarden_license/src/Scim/Models/{GetUserQueryParamModel.cs => GetUsersQueryParamModel.cs} (91%) diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index e3c290c85f..88d6858cb8 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -61,17 +61,15 @@ public class GroupsController : Controller [HttpGet("")] public async Task Get( Guid organizationId, - [FromQuery] string filter, - [FromQuery] int? count, - [FromQuery] int? startIndex) + [FromQuery] GetGroupsQueryParamModel model) { - var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex); + var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model); var scimListResponseModel = new ScimListResponseModel { Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(), - ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()), + ItemsPerPage = model.Count, TotalResults = groupsListQueryResult.totalResults, - StartIndex = startIndex.GetValueOrDefault(1), + StartIndex = model.StartIndex, }; return Ok(scimListResponseModel); } diff --git a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs index cc6546700b..f0a561a29f 100644 --- a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Models; namespace Bit.Scim.Groups; @@ -16,10 +17,16 @@ public class GetGroupsListQuery : IGetGroupsListQuery _groupRepository = groupRepository; } - public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex) + public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync( + Guid organizationId, GetGroupsQueryParamModel groupQueryParams) { string nameFilter = null; string externalIdFilter = null; + + int count = groupQueryParams.Count; + int startIndex = groupQueryParams.StartIndex; + string filter = groupQueryParams.Filter; + if (!string.IsNullOrWhiteSpace(filter)) { if (filter.StartsWith("displayName eq ")) @@ -53,11 +60,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery } totalResults = groupList.Count; } - else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue) + else if (string.IsNullOrWhiteSpace(filter)) { groupList = groups.OrderBy(g => g.Name) - .Skip(startIndex.Value - 1) - .Take(count.Value) + .Skip(startIndex - 1) + .Take(count) .ToList(); totalResults = groups.Count; } diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs index 07ff044701..4b4ba09e1d 100644 --- a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs @@ -1,8 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Scim.Models; namespace Bit.Scim.Groups.Interfaces; public interface IGetGroupsListQuery { - Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex); + Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, GetGroupsQueryParamModel model); } diff --git a/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs new file mode 100644 index 0000000000..5389727917 --- /dev/null +++ b/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Scim.Models; + +public class GetGroupsQueryParamModel +{ + public string Filter { get; init; } = string.Empty; + + [Range(1, int.MaxValue)] + public int Count { get; init; } = 50; + + [Range(1, int.MaxValue)] + public int StartIndex { get; init; } = 1; +} diff --git a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs similarity index 91% rename from bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs rename to bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs index 27d7b6d9a1..cd50dbca61 100644 --- a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs +++ b/bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +namespace Bit.Scim.Models; + public class GetUsersQueryParamModel { public string Filter { get; init; } = string.Empty; diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs index a734635ebf..c7085eb6b9 100644 --- a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; namespace Bit.Scim.Users; diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs index f584cb8e7b..04133c89eb 100644 --- a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Scim.Models; namespace Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs index 5f562a30c5..9ad231a63d 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs @@ -200,6 +200,38 @@ public class GroupsControllerTests : IClassFixture, IAsy AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); } + [Fact] + public async Task GetList_SearchDisplayNameWithoutOptionalParameters_Success() + { + string filter = "displayName eq Test Group 2"; + int? itemsPerPage = null; + int? startIndex = null; + var expectedResponse = new ScimListResponseModel + { + ItemsPerPage = 50, //default value + TotalResults = 1, + StartIndex = 1, //default value + Resources = new List + { + new ScimGroupResponseModel + { + Id = ScimApplicationFactory.TestGroupId2, + DisplayName = "Test Group 2", + ExternalId = "B", + Schemas = new List { ScimConstants.Scim2SchemaGroup } + } + }, + Schemas = new List { ScimConstants.Scim2SchemaListResponse } + }; + + var context = await _factory.GroupsGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + var responseModel = JsonSerializer.Deserialize>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); + } + [Fact] public async Task Post_Success() { diff --git a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs index 1599b6e390..b835e1fe6b 100644 --- a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups; +using Bit.Scim.Models; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -24,7 +25,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, null, count, startIndex); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Count = count, StartIndex = startIndex }); AssertHelper.AssertPropertyEqual(groups.Skip(startIndex - 1).Take(count).ToList(), result.groupList); AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults); @@ -47,7 +48,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -67,7 +68,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -90,7 +91,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -112,7 +113,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); diff --git a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs index 9352e5c202..7424b50c0d 100644 --- a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Scim.Models; using Bit.Scim.Users; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; From ded1c58c27bb14339bdb88c40da7bdee81913f98 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:42:54 -0600 Subject: [PATCH 29/89] [PM-26426] [PM-26427] Remove feature flag - policy validators/requirements refactor (#6674) * chore: remove ff from PoliciesController, refs PM-26426 * chore: remove ff from public PoliciesController, refs PM-26426 * chore: remove ff from VerifyOrganizationDomainCommands, refs PM-26426 * chore: remove ff from SsoConfigService, refs PM-26426 * chore: remove ff from public PoliciesControllerTests, refs PM-26426 * chore: remove ff from PoliciesControllerTests, refs PM-26426 * chore: remove ff from VerifyOrganizationDomainCommandTests, refs PM-26426 * chore: remove ff from SsoConfigServiceTests, refs PM-26426 * chore: remove ff definition, refs PM-26427 * chore: dotnet format * chore: remove unused constructor parameters, refs PM-26426 * chore: fix failing tests for VerifyOrganizationDomainCommandTests and SsoConfigServiceTests, refs PM-26426 --- .../Controllers/PoliciesController.cs | 7 +- .../Public/Controllers/PoliciesController.cs | 27 +--- .../VerifyOrganizationDomainCommand.cs | 14 +-- .../Implementations/SsoConfigService.cs | 24 +--- src/Core/Constants.cs | 1 - .../Controllers/PoliciesControllerTests.cs | 40 +----- .../Controllers/PoliciesControllerTests.cs | 117 +++++------------- .../VerifyOrganizationDomainCommandTests.cs | 19 ++- .../Auth/Services/SsoConfigServiceTests.cs | 34 +++-- 9 files changed, 65 insertions(+), 218 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index a5272413e2..ae1d12e887 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -42,7 +42,6 @@ public class PoliciesController : Controller private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IPolicyRepository _policyRepository; private readonly IUserService _userService; - private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; @@ -55,7 +54,6 @@ public class PoliciesController : Controller IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, - IFeatureService featureService, ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { @@ -69,7 +67,6 @@ public class PoliciesController : Controller _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; - _featureService = featureService; _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -221,9 +218,7 @@ public class PoliciesController : Controller { var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext); - var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? - await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : - await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest); return new PolicyResponseModel(policy); } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index be0997f271..cf8da813be 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -5,15 +5,10 @@ using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; -using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Context; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,25 +19,16 @@ namespace Bit.Api.AdminConsole.Public.Controllers; public class PoliciesController : Controller { private readonly IPolicyRepository _policyRepository; - private readonly IPolicyService _policyService; private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, - IPolicyService policyService, ICurrentContext currentContext, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; - _policyService = policyService; _currentContext = currentContext; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -97,17 +83,8 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model) { - Policy policy; - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); - policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); - } - else - { - var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); - policy = await _savePolicyCommand.SaveAsync(policyUpdate); - } + var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); + var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); var response = new PolicyResponseModel(policy); return new JsonResult(response); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 595e487580..e6cc3da2a2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; @@ -25,8 +24,6 @@ public class VerifyOrganizationDomainCommand( IEventService eventService, IGlobalSettings globalSettings, ICurrentContext currentContext, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand, IMailService mailService, IOrganizationUserRepository organizationUserRepository, @@ -144,15 +141,8 @@ public class VerifyOrganizationDomainCommand( PerformedBy = actingUser }; - if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); - await vNextSavePolicyCommand.SaveAsync(savePolicyModel); - } - else - { - await savePolicyCommand.SaveAsync(policyUpdate); - } + var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); + await vNextSavePolicyCommand.SaveAsync(savePolicyModel); } private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain) diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index 1a35585b2c..0cb8b68042 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -26,8 +25,6 @@ public class SsoConfigService : ISsoConfigService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public SsoConfigService( @@ -36,8 +33,6 @@ public class SsoConfigService : ISsoConfigService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; @@ -45,8 +40,6 @@ public class SsoConfigService : ISsoConfigService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -97,19 +90,10 @@ public class SsoConfigService : ISsoConfigService Enabled = true }; - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var performedBy = new SystemUser(EventSystemUser.Unknown); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); - } - else - { - await _savePolicyCommand.SaveAsync(singleOrgPolicy); - await _savePolicyCommand.SaveAsync(resetPasswordPolicy); - await _savePolicyCommand.SaveAsync(requireSsoPolicy); - } + var performedBy = new SystemUser(EventSystemUser.Unknown); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0d3dd37df4..0a26e6f324 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -141,7 +141,6 @@ public static class FeatureFlagKeys public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; - public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; /* Architecture */ diff --git a/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs index c2360f5f9a..bd10eab617 100644 --- a/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -1,14 +1,11 @@ using Bit.Api.AdminConsole.Public.Controllers; using Bit.Api.AdminConsole.Public.Models.Request; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -22,7 +19,7 @@ public class PoliciesControllerTests { [Theory] [BitAutoData] - public async Task Put_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + public async Task Put_UsesVNextSavePolicyCommand( Guid organizationId, PolicyType policyType, PolicyUpdateRequestModel model, @@ -33,9 +30,6 @@ public class PoliciesControllerTests policy.Data = null; sutProvider.GetDependency() .OrganizationId.Returns(organizationId); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) - .Returns(true); sutProvider.GetDependency() .SaveAsync(Arg.Any()) .Returns(policy); @@ -52,36 +46,4 @@ public class PoliciesControllerTests m.PolicyUpdate.Enabled == model.Enabled.GetValueOrDefault() && m.PerformedBy is SystemUser)); } - - [Theory] - [BitAutoData] - public async Task Put_WhenPolicyValidatorsRefactorDisabled_UsesLegacySavePolicyCommand( - Guid organizationId, - PolicyType policyType, - PolicyUpdateRequestModel model, - Policy policy, - SutProvider sutProvider) - { - // Arrange - policy.Data = null; - sutProvider.GetDependency() - .OrganizationId.Returns(organizationId); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) - .Returns(false); - sutProvider.GetDependency() - .SaveAsync(Arg.Any()) - .Returns(policy); - - // Act - await sutProvider.Sut.Put(policyType, model); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .SaveAsync(Arg.Is(p => - p.OrganizationId == organizationId && - p.Type == policyType && - p.Enabled == model.Enabled)); - } } diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index 89d6ddefdc..efb9f7aaa9 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -291,7 +290,7 @@ public class PoliciesControllerTests string token, string email, Organization organization - ) + ) { // Arrange organization.UsePolicies = true; @@ -302,14 +301,15 @@ public class PoliciesControllerTests var decryptedToken = Substitute.For(); decryptedToken.Valid.Returns(false); - var orgUserInviteTokenDataFactory = sutProvider.GetDependency>(); + var orgUserInviteTokenDataFactory = + sutProvider.GetDependency>(); orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any()) .Returns(x => - { - x[1] = decryptedToken; - return true; - }); + { + x[1] = decryptedToken; + return true; + }); // Act & Assert await Assert.ThrowsAsync(() => @@ -325,7 +325,7 @@ public class PoliciesControllerTests string token, string email, Organization organization - ) + ) { // Arrange organization.UsePolicies = true; @@ -338,14 +338,15 @@ public class PoliciesControllerTests decryptedToken.OrgUserId = organizationUserId; decryptedToken.OrgUserEmail = email; - var orgUserInviteTokenDataFactory = sutProvider.GetDependency>(); + var orgUserInviteTokenDataFactory = + sutProvider.GetDependency>(); orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any()) .Returns(x => - { - x[1] = decryptedToken; - return true; - }); + { + x[1] = decryptedToken; + return true; + }); sutProvider.GetDependency() .GetByIdAsync(organizationUserId) @@ -366,7 +367,7 @@ public class PoliciesControllerTests string email, OrganizationUser orgUser, Organization organization - ) + ) { // Arrange organization.UsePolicies = true; @@ -379,14 +380,15 @@ public class PoliciesControllerTests decryptedToken.OrgUserId = organizationUserId; decryptedToken.OrgUserEmail = email; - var orgUserInviteTokenDataFactory = sutProvider.GetDependency>(); + var orgUserInviteTokenDataFactory = + sutProvider.GetDependency>(); orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any()) .Returns(x => - { - x[1] = decryptedToken; - return true; - }); + { + x[1] = decryptedToken; + return true; + }); orgUser.OrganizationId = Guid.Empty; @@ -409,7 +411,7 @@ public class PoliciesControllerTests string email, OrganizationUser orgUser, Organization organization - ) + ) { // Arrange organization.UsePolicies = true; @@ -422,14 +424,15 @@ public class PoliciesControllerTests decryptedToken.OrgUserId = organizationUserId; decryptedToken.OrgUserEmail = email; - var orgUserInviteTokenDataFactory = sutProvider.GetDependency>(); + var orgUserInviteTokenDataFactory = + sutProvider.GetDependency>(); orgUserInviteTokenDataFactory.TryUnprotect(token, out Arg.Any()) .Returns(x => - { - x[1] = decryptedToken; - return true; - }); + { + x[1] = decryptedToken; + return true; + }); orgUser.OrganizationId = orgId; sutProvider.GetDependency() @@ -463,7 +466,7 @@ public class PoliciesControllerTests [Theory] [BitAutoData] - public async Task PutVNext_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + public async Task PutVNext_UsesVNextSavePolicyCommand( SutProvider sutProvider, Guid orgId, SavePolicyRequest model, Policy policy, Guid userId) { @@ -478,10 +481,6 @@ public class PoliciesControllerTests .OrganizationOwner(orgId) .Returns(true); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) - .Returns(true); - sutProvider.GetDependency() .SaveAsync(Arg.Any()) .Returns(policy); @@ -492,12 +491,11 @@ public class PoliciesControllerTests // Assert await sutProvider.GetDependency() .Received(1) - .SaveAsync(Arg.Is( - m => m.PolicyUpdate.OrganizationId == orgId && - m.PolicyUpdate.Type == policy.Type && - m.PolicyUpdate.Enabled == model.Policy.Enabled && - m.PerformedBy.UserId == userId && - m.PerformedBy.IsOrganizationOwnerOrProvider == true)); + .SaveAsync(Arg.Is(m => m.PolicyUpdate.OrganizationId == orgId && + m.PolicyUpdate.Type == policy.Type && + m.PolicyUpdate.Enabled == model.Policy.Enabled && + m.PerformedBy.UserId == userId && + m.PerformedBy.IsOrganizationOwnerOrProvider == true)); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -507,51 +505,4 @@ public class PoliciesControllerTests Assert.Equal(policy.Id, result.Id); Assert.Equal(policy.Type, result.Type); } - - [Theory] - [BitAutoData] - public async Task PutVNext_WhenPolicyValidatorsRefactorDisabled_UsesSavePolicyCommand( - SutProvider sutProvider, Guid orgId, - SavePolicyRequest model, Policy policy, Guid userId) - { - // Arrange - policy.Data = null; - - sutProvider.GetDependency() - .UserId - .Returns(userId); - - sutProvider.GetDependency() - .OrganizationOwner(orgId) - .Returns(true); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) - .Returns(false); - - sutProvider.GetDependency() - .VNextSaveAsync(Arg.Any()) - .Returns(policy); - - // Act - var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .VNextSaveAsync(Arg.Is( - m => m.PolicyUpdate.OrganizationId == orgId && - m.PolicyUpdate.Type == policy.Type && - m.PolicyUpdate.Enabled == model.Policy.Enabled && - m.PerformedBy.UserId == userId && - m.PerformedBy.IsOrganizationOwnerOrProvider == true)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SaveAsync(default); - - Assert.NotNull(result); - Assert.Equal(policy.Id, result.Id); - Assert.Equal(policy.Type, result.Type); - } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 3f0443d31b..ef4c2c941e 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; @@ -183,17 +182,17 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .SaveAsync(Arg.Is(x => x.Type == PolicyType.SingleOrg && - x.OrganizationId == domain.OrganizationId && - x.Enabled && + .SaveAsync(Arg.Is(x => x.PolicyUpdate.Type == PolicyType.SingleOrg && + x.PolicyUpdate.OrganizationId == domain.OrganizationId && + x.PolicyUpdate.Enabled && x.PerformedBy is StandardUser && x.PerformedBy.UserId == userId)); } [Theory, BitAutoData] - public async Task UserVerifyOrganizationDomainAsync_WhenPolicyValidatorsRefactorFlagEnabled_UsesVNextSavePolicyCommand( + public async Task UserVerifyOrganizationDomainAsync_UsesVNextSavePolicyCommand( OrganizationDomain domain, Guid userId, SutProvider sutProvider) { sutProvider.GetDependency() @@ -207,10 +206,6 @@ public class VerifyOrganizationDomainCommandTests sutProvider.GetDependency() .UserId.Returns(userId); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) - .Returns(true); - _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); await sutProvider.GetDependency() @@ -240,9 +235,9 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .SaveAsync(Arg.Any()); + .SaveAsync(Arg.Any()); } [Theory, BitAutoData] diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index 7319df17aa..2f4d00a7fa 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -2,7 +2,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -14,7 +13,6 @@ using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -342,26 +340,26 @@ public class SsoConfigServiceTests await sutProvider.Sut.SaveAsync(ssoConfig, organization); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SaveAsync( - Arg.Is(t => t.Type == PolicyType.SingleOrg && - t.OrganizationId == organization.Id && - t.Enabled) + Arg.Is(t => t.PolicyUpdate.Type == PolicyType.SingleOrg && + t.PolicyUpdate.OrganizationId == organization.Id && + t.PolicyUpdate.Enabled) ); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SaveAsync( - Arg.Is(t => t.Type == PolicyType.ResetPassword && - t.GetDataModel().AutoEnrollEnabled && - t.OrganizationId == organization.Id && - t.Enabled) + Arg.Is(t => t.PolicyUpdate.Type == PolicyType.ResetPassword && + t.PolicyUpdate.GetDataModel().AutoEnrollEnabled && + t.PolicyUpdate.OrganizationId == organization.Id && + t.PolicyUpdate.Enabled) ); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SaveAsync( - Arg.Is(t => t.Type == PolicyType.RequireSso && - t.OrganizationId == organization.Id && - t.Enabled) + Arg.Is(t => t.PolicyUpdate.Type == PolicyType.RequireSso && + t.PolicyUpdate.OrganizationId == organization.Id && + t.PolicyUpdate.Enabled) ); await sutProvider.GetDependency().ReceivedWithAnyArgs() @@ -369,7 +367,7 @@ public class SsoConfigServiceTests } [Theory, BitAutoData] - public async Task SaveAsync_Tde_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + public async Task SaveAsync_Tde_UsesVNextSavePolicyCommand( SutProvider sutProvider, Organization organization) { var ssoConfig = new SsoConfig @@ -383,10 +381,6 @@ public class SsoConfigServiceTests OrganizationId = organization.Id, }; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) - .Returns(true); - await sutProvider.Sut.SaveAsync(ssoConfig, organization); await sutProvider.GetDependency() From 98212a7f49104a0f2434e85b8f9558daf002f93b Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:17:29 -0500 Subject: [PATCH 30/89] [SM-1592] API for Secret Versioning, adding controller, repository and tests (#6444) * Adding SecretVersion table to server * making the names singular not plural for new table * removing migration * fixing migration * Adding indexes for serviceacct and orguserId * indexes for sqllite * fixing migrations * adding indexes to secretVeriosn.sql * tests * removing tests * adding GO * api repository and controller additions for SecretVersion table, as well as tests * test fix sqllite * improvements * removing comments * making files nullable safe * Justin Baurs suggested changes * claude suggestions * Claude fixes * test fixes --- .../Repositories/SecretVersionRepository.cs | 94 +++++ ...etsManagerEFServiceCollectionExtensions.cs | 1 + .../SecretVersionRepositoryTests.cs | 130 +++++++ .../Controllers/SecretVersionsController.cs | 337 ++++++++++++++++++ .../Controllers/SecretsController.cs | 47 ++- .../RestoreSecretVersionRequestModel.cs | 9 + .../Request/SecretUpdateRequestModel.cs | 2 + .../Response/SecretVersionResponseModel.cs | 28 ++ .../Repositories/ISecretVersionRepository.cs | 12 + .../Noop/NoopSecretVersionRepository.cs | 31 ++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../SecretVersionsControllerTests.cs | 289 +++++++++++++++ .../SecretVersionsControllerTests.cs | 307 ++++++++++++++++ .../Controllers/SecretsControllerTests.cs | 3 + 14 files changed, 1290 insertions(+), 1 deletion(-) create mode 100644 bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs create mode 100644 bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs create mode 100644 src/Api/SecretsManager/Controllers/SecretVersionsController.cs create mode 100644 src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs create mode 100644 src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs create mode 100644 src/Core/SecretsManager/Repositories/ISecretVersionRepository.cs create mode 100644 src/Core/SecretsManager/Repositories/Noop/NoopSecretVersionRepository.cs create mode 100644 test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs create mode 100644 test/Api.Test/SecretsManager/Controllers/SecretVersionsControllerTests.cs diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs new file mode 100644 index 0000000000..22421f9921 --- /dev/null +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs @@ -0,0 +1,94 @@ +using AutoMapper; +using Bit.Core.SecretsManager.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.SecretsManager.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories; + +public class SecretVersionRepository : Repository, ISecretVersionRepository +{ + public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, db => db.SecretVersion) + { } + + public override async Task GetByIdAsync(Guid id) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var secretVersion = await dbContext.SecretVersion + .Where(sv => sv.Id == id) + .FirstOrDefaultAsync(); + return Mapper.Map(secretVersion); + } + + public async Task> GetManyBySecretIdAsync(Guid secretId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var secretVersions = await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretId) + .OrderByDescending(sv => sv.VersionDate) + .ToListAsync(); + return Mapper.Map>(secretVersions); + } + + public async Task> GetManyByIdsAsync(IEnumerable ids) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var versionIds = ids.ToList(); + var secretVersions = await dbContext.SecretVersion + .Where(sv => versionIds.Contains(sv.Id)) + .OrderByDescending(sv => sv.VersionDate) + .ToListAsync(); + return Mapper.Map>(secretVersions); + } + + public override async Task CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion) + { + const int maxVersionsToKeep = 10; + + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep + var versionsToKeepIds = await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretVersion.SecretId) + .OrderByDescending(sv => sv.VersionDate) + .Take(maxVersionsToKeep - 1) + .Select(sv => sv.Id) + .ToListAsync(); + + // Delete all versions for this secret that are not in the "keep" list + if (versionsToKeepIds.Any()) + { + await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id)) + .ExecuteDeleteAsync(); + } + + secretVersion.SetNewId(); + var entity = Mapper.Map(secretVersion); + + await dbContext.AddAsync(entity); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + + return secretVersion; + } + + public async Task DeleteManyByIdAsync(IEnumerable ids) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + var secretVersionIds = ids.ToList(); + await dbContext.SecretVersion + .Where(sv => secretVersionIds.Contains(sv.Id)) + .ExecuteDeleteAsync(); + } +} diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs index d6c8848079..ac52c40ba6 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs new file mode 100644 index 0000000000..659a6d1233 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs @@ -0,0 +1,130 @@ +using Bit.Core.SecretsManager.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Repositories; + +public class SecretVersionRepositoryTests +{ + [Theory] + [BitAutoData] + public void SecretVersion_EntityCreation_Success(SecretVersion secretVersion) + { + // Arrange & Act + secretVersion.SetNewId(); + + // Assert + Assert.NotEqual(Guid.Empty, secretVersion.Id); + Assert.NotEqual(Guid.Empty, secretVersion.SecretId); + Assert.NotNull(secretVersion.Value); + Assert.NotEqual(default, secretVersion.VersionDate); + } + + [Theory] + [BitAutoData] + public void SecretVersion_WithServiceAccountEditor_Success(SecretVersion secretVersion, Guid serviceAccountId) + { + // Arrange & Act + secretVersion.EditorServiceAccountId = serviceAccountId; + secretVersion.EditorOrganizationUserId = null; + + // Assert + Assert.Equal(serviceAccountId, secretVersion.EditorServiceAccountId); + Assert.Null(secretVersion.EditorOrganizationUserId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_WithOrganizationUserEditor_Success(SecretVersion secretVersion, Guid organizationUserId) + { + // Arrange & Act + secretVersion.EditorOrganizationUserId = organizationUserId; + secretVersion.EditorServiceAccountId = null; + + // Assert + Assert.Equal(organizationUserId, secretVersion.EditorOrganizationUserId); + Assert.Null(secretVersion.EditorServiceAccountId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_NullableEditors_Success(SecretVersion secretVersion) + { + // Arrange & Act + secretVersion.EditorServiceAccountId = null; + secretVersion.EditorOrganizationUserId = null; + + // Assert + Assert.Null(secretVersion.EditorServiceAccountId); + Assert.Null(secretVersion.EditorOrganizationUserId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_VersionDateSet_Success(SecretVersion secretVersion) + { + // Arrange + var versionDate = DateTime.UtcNow; + + // Act + secretVersion.VersionDate = versionDate; + + // Assert + Assert.Equal(versionDate, secretVersion.VersionDate); + } + + [Theory] + [BitAutoData] + public void SecretVersion_ValueEncrypted_Success(SecretVersion secretVersion, string encryptedValue) + { + // Arrange & Act + secretVersion.Value = encryptedValue; + + // Assert + Assert.Equal(encryptedValue, secretVersion.Value); + Assert.NotEmpty(secretVersion.Value); + } + + [Theory] + [BitAutoData] + public void SecretVersion_MultipleVersions_DifferentIds(List secretVersions, Guid secretId) + { + // Arrange & Act + foreach (var version in secretVersions) + { + version.SecretId = secretId; + version.SetNewId(); + } + + // Assert + var distinctIds = secretVersions.Select(v => v.Id).Distinct(); + Assert.Equal(secretVersions.Count, distinctIds.Count()); + Assert.All(secretVersions, v => Assert.Equal(secretId, v.SecretId)); + } + + [Theory] + [BitAutoData] + public void SecretVersion_VersionDateOrdering_Success(SecretVersion version1, SecretVersion version2, SecretVersion version3, Guid secretId) + { + // Arrange + var now = DateTime.UtcNow; + version1.SecretId = secretId; + version1.VersionDate = now.AddDays(-2); + + version2.SecretId = secretId; + version2.VersionDate = now.AddDays(-1); + + version3.SecretId = secretId; + version3.VersionDate = now; + + var versions = new List { version2, version3, version1 }; + + // Act + var orderedVersions = versions.OrderByDescending(v => v.VersionDate).ToList(); + + // Assert + Assert.Equal(version3.Id, orderedVersions[0].Id); // Most recent + Assert.Equal(version2.Id, orderedVersions[1].Id); + Assert.Equal(version1.Id, orderedVersions[2].Id); // Oldest + } +} diff --git a/src/Api/SecretsManager/Controllers/SecretVersionsController.cs b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs new file mode 100644 index 0000000000..86e2d1f7e9 --- /dev/null +++ b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs @@ -0,0 +1,337 @@ +using Bit.Api.Models.Response; +using Bit.Api.SecretsManager.Models.Request; +using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.SecretsManager.Controllers; + +[Authorize("secrets")] +public class SecretVersionsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly ISecretVersionRepository _secretVersionRepository; + private readonly ISecretRepository _secretRepository; + private readonly IUserService _userService; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public SecretVersionsController( + ICurrentContext currentContext, + ISecretVersionRepository secretVersionRepository, + ISecretRepository secretRepository, + IUserService userService, + IOrganizationUserRepository organizationUserRepository) + { + _currentContext = currentContext; + _secretVersionRepository = secretVersionRepository; + _secretRepository = secretRepository; + _userService = userService; + _organizationUserRepository = organizationUserRepository; + } + + [HttpGet("secrets/{secretId}/versions")] + public async Task> GetVersionsBySecretIdAsync([FromRoute] Guid secretId) + { + var secret = await _secretRepository.GetByIdAsync(secretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access above + var versionList = await _secretVersionRepository.GetManyBySecretIdAsync(secretId); + var responseList = versionList.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(responseList); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient); + if (!access.Read) + { + throw new NotFoundException(); + } + + var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secretId); + var responses = versions.Select(v => new SecretVersionResponseModel(v)); + + return new ListResponseModel(responses); + } + + [HttpGet("secret-versions/{id}")] + public async Task GetByIdAsync([FromRoute] Guid id) + { + var secretVersion = await _secretVersionRepository.GetByIdAsync(id); + if (secretVersion == null) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(secretVersion.SecretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access above + return new SecretVersionResponseModel(secretVersion); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretVersion.SecretId, userId.Value, accessClient); + if (!access.Read) + { + throw new NotFoundException(); + } + + return new SecretVersionResponseModel(secretVersion); + } + + [HttpPost("secret-versions/get-by-ids")] + public async Task> GetManyByIdsAsync([FromBody] List ids) + { + if (!ids.Any()) + { + throw new BadRequestException("No version IDs provided."); + } + + // Get all versions + var versions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList(); + if (!versions.Any()) + { + throw new NotFoundException(); + } + + // Get all associated secrets and check permissions + var secretIds = versions.Select(v => v.SecretId).Distinct().ToList(); + var secrets = (await _secretRepository.GetManyByIds(secretIds)).ToList(); + + if (!secrets.Any()) + { + throw new NotFoundException(); + } + + // Ensure all secrets belong to the same organization + var organizationId = secrets.First().OrganizationId; + if (secrets.Any(s => s.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access and organization ownership above + var serviceAccountResponses = versions.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(serviceAccountResponses); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var isAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin); + + // Verify read access to all associated secrets + var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient); + if (accessResults.Values.Any(access => !access.Read)) + { + throw new NotFoundException(); + } + + var responses = versions.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(responses); + } + + [HttpPut("secrets/{secretId}/versions/restore")] + public async Task RestoreVersionAsync([FromRoute] Guid secretId, [FromBody] RestoreSecretVersionRequestModel request) + { + if (!(_currentContext.IdentityClientType == IdentityClientType.User || _currentContext.IdentityClientType == IdentityClientType.ServiceAccount)) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(secretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // Get the version first to validate it belongs to this secret + var version = await _secretVersionRepository.GetByIdAsync(request.VersionId); + if (version == null || version.SecretId != secretId) + { + throw new NotFoundException(); + } + + // Store the current value before restoration + var currentValue = secret.Value; + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + { + // Save current value as a version before restoring + if (currentValue != version.Value) + { + var editorUserId = _userService.GetProperUserId(User); + if (editorUserId.HasValue) + { + var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion + { + SecretId = secretId, + Value = currentValue!, + VersionDate = DateTime.UtcNow, + EditorServiceAccountId = editorUserId.Value + }; + + await _secretVersionRepository.CreateAsync(currentVersionSnapshot); + } + } + + // Already verified Secrets Manager access above + secret.Value = version.Value; + secret.RevisionDate = DateTime.UtcNow; + var updatedSec = await _secretRepository.UpdateAsync(secret); + return new SecretResponseModel(updatedSec, true, true); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient); + if (!access.Write) + { + throw new NotFoundException(); + } + + // Save current value as a version before restoring + if (currentValue != version.Value) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId.Value); + if (orgUser == null) + { + throw new NotFoundException(); + } + + var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion + { + SecretId = secretId, + Value = currentValue!, + VersionDate = DateTime.UtcNow, + EditorOrganizationUserId = orgUser.Id + }; + + await _secretVersionRepository.CreateAsync(currentVersionSnapshot); + } + + // Update the secret with the version's value + secret.Value = version.Value; + secret.RevisionDate = DateTime.UtcNow; + + var updatedSecret = await _secretRepository.UpdateAsync(secret); + + return new SecretResponseModel(updatedSecret, true, true); + } + + [HttpPost("secret-versions/delete")] + public async Task BulkDeleteAsync([FromBody] List ids) + { + if (!ids.Any()) + { + throw new BadRequestException("No version IDs provided."); + } + + var secretVersions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList(); + if (secretVersions.Count != ids.Count) + { + throw new NotFoundException(); + } + + // Ensure all versions belong to secrets in the same organization + var secretIds = secretVersions.Select(v => v.SecretId).Distinct().ToList(); + var secrets = await _secretRepository.GetManyByIds(secretIds); + var secretsList = secrets.ToList(); + + if (!secretsList.Any()) + { + throw new NotFoundException(); + } + + var organizationId = secretsList.First().OrganizationId; + if (secretsList.Any(s => s.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access and organization ownership above + await _secretVersionRepository.DeleteManyByIdAsync(ids); + return Ok(); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + // Verify write access to all associated secrets + var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient); + if (accessResults.Values.Any(access => !access.Write)) + { + throw new NotFoundException(); + } + + await _secretVersionRepository.DeleteManyByIdAsync(ids); + + return Ok(); + } +} diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index e263b9747d..dcfe1be111 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; @@ -29,6 +30,7 @@ public class SecretsController : Controller private readonly ICurrentContext _currentContext; private readonly IProjectRepository _projectRepository; private readonly ISecretRepository _secretRepository; + private readonly ISecretVersionRepository _secretVersionRepository; private readonly ICreateSecretCommand _createSecretCommand; private readonly IUpdateSecretCommand _updateSecretCommand; private readonly IDeleteSecretCommand _deleteSecretCommand; @@ -38,11 +40,13 @@ public class SecretsController : Controller private readonly IUserService _userService; private readonly IEventService _eventService; private readonly IAuthorizationService _authorizationService; + private readonly IOrganizationUserRepository _organizationUserRepository; public SecretsController( ICurrentContext currentContext, IProjectRepository projectRepository, ISecretRepository secretRepository, + ISecretVersionRepository secretVersionRepository, ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand, IDeleteSecretCommand deleteSecretCommand, @@ -51,11 +55,13 @@ public class SecretsController : Controller ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery, IUserService userService, IEventService eventService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IOrganizationUserRepository organizationUserRepository) { _currentContext = currentContext; _projectRepository = projectRepository; _secretRepository = secretRepository; + _secretVersionRepository = secretVersionRepository; _createSecretCommand = createSecretCommand; _updateSecretCommand = updateSecretCommand; _deleteSecretCommand = deleteSecretCommand; @@ -65,6 +71,7 @@ public class SecretsController : Controller _userService = userService; _eventService = eventService; _authorizationService = authorizationService; + _organizationUserRepository = organizationUserRepository; } @@ -190,6 +197,44 @@ public class SecretsController : Controller } } + // Create a version record if the value changed + if (updateRequest.ValueChanged) + { + // Store the old value before updating + var oldValue = secret.Value; + var userId = _userService.GetProperUserId(User)!.Value; + Guid? editorServiceAccountId = null; + Guid? editorOrganizationUserId = null; + + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + { + editorServiceAccountId = userId; + } + else if (_currentContext.IdentityClientType == IdentityClientType.User) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId); + if (orgUser != null) + { + editorOrganizationUserId = orgUser.Id; + } + else + { + throw new NotFoundException(); + } + } + + var secretVersion = new SecretVersion + { + SecretId = id, + Value = oldValue, + VersionDate = DateTime.UtcNow, + EditorServiceAccountId = editorServiceAccountId, + EditorOrganizationUserId = editorOrganizationUserId + }; + + await _secretVersionRepository.CreateAsync(secretVersion); + } + var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates); await LogSecretEventAsync(secret, EventType.Secret_Edited); diff --git a/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs b/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs new file mode 100644 index 0000000000..19a6b35a75 --- /dev/null +++ b/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.SecretsManager.Models.Request; + +public class RestoreSecretVersionRequestModel +{ + [Required] + public Guid VersionId { get; set; } +} diff --git a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs index b95bc9e500..9d19e1d8cc 100644 --- a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs @@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; } + public bool ValueChanged { get; set; } = false; + public Secret ToSecret(Secret secret) { secret.Key = Key; diff --git a/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs new file mode 100644 index 0000000000..07b8e88f7e --- /dev/null +++ b/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs @@ -0,0 +1,28 @@ +using Bit.Core.Models.Api; +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Api.SecretsManager.Models.Response; + +public class SecretVersionResponseModel : ResponseModel +{ + private const string _objectName = "secretVersion"; + + public Guid Id { get; set; } + public Guid SecretId { get; set; } + public string Value { get; set; } = string.Empty; + public DateTime VersionDate { get; set; } + public Guid? EditorServiceAccountId { get; set; } + public Guid? EditorOrganizationUserId { get; set; } + + public SecretVersionResponseModel() : base(_objectName) { } + + public SecretVersionResponseModel(SecretVersion secretVersion) : base(_objectName) + { + Id = secretVersion.Id; + SecretId = secretVersion.SecretId; + Value = secretVersion.Value; + VersionDate = secretVersion.VersionDate; + EditorServiceAccountId = secretVersion.EditorServiceAccountId; + EditorOrganizationUserId = secretVersion.EditorOrganizationUserId; + } +} diff --git a/src/Core/SecretsManager/Repositories/ISecretVersionRepository.cs b/src/Core/SecretsManager/Repositories/ISecretVersionRepository.cs new file mode 100644 index 0000000000..b6dd1d778d --- /dev/null +++ b/src/Core/SecretsManager/Repositories/ISecretVersionRepository.cs @@ -0,0 +1,12 @@ +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Core.SecretsManager.Repositories; + +public interface ISecretVersionRepository +{ + Task GetByIdAsync(Guid id); + Task> GetManyBySecretIdAsync(Guid secretId); + Task> GetManyByIdsAsync(IEnumerable ids); + Task CreateAsync(SecretVersion secretVersion); + Task DeleteManyByIdAsync(IEnumerable ids); +} diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopSecretVersionRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopSecretVersionRepository.cs new file mode 100644 index 0000000000..caa5d96a7c --- /dev/null +++ b/src/Core/SecretsManager/Repositories/Noop/NoopSecretVersionRepository.cs @@ -0,0 +1,31 @@ +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Core.SecretsManager.Repositories.Noop; + +public class NoopSecretVersionRepository : ISecretVersionRepository +{ + public Task GetByIdAsync(Guid id) + { + return Task.FromResult(null as SecretVersion); + } + + public Task> GetManyBySecretIdAsync(Guid secretId) + { + return Task.FromResult(Enumerable.Empty()); + } + + public Task CreateAsync(SecretVersion secretVersion) + { + return Task.FromResult(secretVersion); + } + + public Task DeleteManyByIdAsync(IEnumerable ids) + { + return Task.CompletedTask; + } + + public Task> GetManyByIdsAsync(IEnumerable ids) + { + return Task.FromResult(Enumerable.Empty()); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 79f46ecb74..587ddb65a4 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -344,6 +344,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs new file mode 100644 index 0000000000..9393795e55 --- /dev/null +++ b/test/Api.IntegrationTest/SecretsManager/Controllers/SecretVersionsControllerTests.cs @@ -0,0 +1,289 @@ +using System.Net; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.SecretsManager.Enums; +using Bit.Api.IntegrationTest.SecretsManager.Helpers; +using Bit.Api.Models.Response; +using Bit.Api.SecretsManager.Models.Request; +using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Enums; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.SecretsManager.Controllers; + +public class SecretVersionsControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly string _mockEncryptedString = + "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly ISecretRepository _secretRepository; + private readonly ISecretVersionRepository _secretVersionRepository; + private readonly IAccessPolicyRepository _accessPolicyRepository; + private readonly LoginHelper _loginHelper; + + private string _email = null!; + private SecretsManagerOrganizationHelper _organizationHelper = null!; + + public SecretVersionsControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + _secretRepository = _factory.GetService(); + _secretVersionRepository = _factory.GetService(); + _accessPolicyRepository = _factory.GetService(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_email); + _organizationHelper = new SecretsManagerOrganizationHelper(_factory, _email); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public async Task GetVersionsBySecretId_SmAccessDenied_NotFound(bool useSecrets, bool accessSecrets, bool organizationEnabled) + { + var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets, organizationEnabled); + await _loginHelper.LoginAsync(_email); + + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = org.Id, + Key = _mockEncryptedString, + Value = _mockEncryptedString, + Note = _mockEncryptedString + }); + + var response = await _client.GetAsync($"/secrets/{secret.Id}/versions"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData(PermissionType.RunAsAdmin)] + [InlineData(PermissionType.RunAsUserWithPermission)] + public async Task GetVersionsBySecretId_Success(PermissionType permissionType) + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = org.Id, + Key = _mockEncryptedString, + Value = _mockEncryptedString, + Note = _mockEncryptedString + }); + + // Create some versions + var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = _mockEncryptedString, + VersionDate = DateTime.UtcNow.AddDays(-2) + }); + + var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = _mockEncryptedString, + VersionDate = DateTime.UtcNow.AddDays(-1) + }); + + if (permissionType == PermissionType.RunAsUserWithPermission) + { + var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true); + await _loginHelper.LoginAsync(email); + + var accessPolicies = new List + { + new UserSecretAccessPolicy + { + GrantedSecretId = secret.Id, + OrganizationUserId = orgUser.Id, + Read = true, + Write = true + } + }; + await _accessPolicyRepository.CreateManyAsync(accessPolicies); + } + + var response = await _client.GetAsync($"/secrets/{secret.Id}/versions"); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync>(); + + Assert.NotNull(result); + Assert.Equal(2, result.Data.Count()); + } + + [Fact] + public async Task GetVersionById_Success() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = org.Id, + Key = _mockEncryptedString, + Value = _mockEncryptedString, + Note = _mockEncryptedString + }); + + var version = await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = _mockEncryptedString, + VersionDate = DateTime.UtcNow + }); + + var response = await _client.GetAsync($"/secret-versions/{version.Id}"); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.Equal(version.Id, result.Id); + Assert.Equal(secret.Id, result.SecretId); + } + + [Fact] + public async Task RestoreVersion_Success() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = org.Id, + Key = _mockEncryptedString, + Value = "OriginalValue", + Note = _mockEncryptedString + }); + + var version = await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = "OldValue", + VersionDate = DateTime.UtcNow.AddDays(-1) + }); + + var request = new RestoreSecretVersionRequestModel + { + VersionId = version.Id + }; + + var response = await _client.PutAsJsonAsync($"/secrets/{secret.Id}/versions/restore", request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.Equal("OldValue", result.Value); + } + + [Fact] + public async Task BulkDelete_Success() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = org.Id, + Key = _mockEncryptedString, + Value = _mockEncryptedString, + Note = _mockEncryptedString + }); + + var version1 = await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = _mockEncryptedString, + VersionDate = DateTime.UtcNow.AddDays(-2) + }); + + var version2 = await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = _mockEncryptedString, + VersionDate = DateTime.UtcNow.AddDays(-1) + }); + + var ids = new List { version1.Id, version2.Id }; + + var response = await _client.PostAsJsonAsync("/secret-versions/delete", ids); + response.EnsureSuccessStatusCode(); + + var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secret.Id); + Assert.Empty(versions); + } + + [Fact] + public async Task GetVersionsBySecretId_ReturnsOrderedByVersionDate() + { + var (org, _) = await _organizationHelper.Initialize(true, true, true); + await _loginHelper.LoginAsync(_email); + + var secret = await _secretRepository.CreateAsync(new Secret + { + OrganizationId = org.Id, + Key = _mockEncryptedString, + Value = _mockEncryptedString, + Note = _mockEncryptedString + }); + + // Create versions in random order + await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = "Version2", + VersionDate = DateTime.UtcNow.AddDays(-1) + }); + + await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = "Version3", + VersionDate = DateTime.UtcNow + }); + + await _secretVersionRepository.CreateAsync(new SecretVersion + { + SecretId = secret.Id, + Value = "Version1", + VersionDate = DateTime.UtcNow.AddDays(-2) + }); + + var response = await _client.GetAsync($"/secrets/{secret.Id}/versions"); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync>(); + + Assert.NotNull(result); + Assert.Equal(3, result.Data.Count()); + + var versions = result.Data.ToList(); + // Should be ordered by VersionDate descending (newest first) + Assert.Equal("Version3", versions[0].Value); + Assert.Equal("Version2", versions[1].Value); + Assert.Equal("Version1", versions[2].Value); + } +} diff --git a/test/Api.Test/SecretsManager/Controllers/SecretVersionsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/SecretVersionsControllerTests.cs new file mode 100644 index 0000000000..79a339fcba --- /dev/null +++ b/test/Api.Test/SecretsManager/Controllers/SecretVersionsControllerTests.cs @@ -0,0 +1,307 @@ +using Bit.Api.SecretsManager.Controllers; +using Bit.Api.SecretsManager.Models.Request; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.SecretsManager.AutoFixture.SecretsFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.SecretsManager.Controllers; + +[ControllerCustomize(typeof(SecretVersionsController))] +[SutProviderCustomize] +[SecretCustomize] +public class SecretVersionsControllerTests +{ + [Theory] + [BitAutoData] + public async Task GetVersionsBySecretId_SecretNotFound_Throws( + SutProvider sutProvider, + Guid secretId) + { + sutProvider.GetDependency().GetByIdAsync(secretId).Returns((Secret?)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetVersionsBySecretIdAsync(secretId)); + } + + [Theory] + [BitAutoData] + public async Task GetVersionsBySecretId_NoAccess_Throws( + SutProvider sutProvider, + Secret secret) + { + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(false); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id)); + } + + [Theory] + [BitAutoData] + public async Task GetVersionsBySecretId_NoReadAccess_Throws( + SutProvider sutProvider, + Secret secret, + Guid userId) + { + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(false); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((false, false)); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id)); + } + + [Theory] + [BitAutoData] + public async Task GetVersionsBySecretId_Success( + SutProvider sutProvider, + Secret secret, + List versions, + Guid userId) + { + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(false); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, false)); + + foreach (var version in versions) + { + version.SecretId = secret.Id; + } + sutProvider.GetDependency().GetManyBySecretIdAsync(secret.Id).Returns(versions); + + var result = await sutProvider.Sut.GetVersionsBySecretIdAsync(secret.Id); + + Assert.Equal(versions.Count, result.Data.Count()); + await sutProvider.GetDependency().Received(1) + .GetManyBySecretIdAsync(Arg.Is(secret.Id)); + } + + [Theory] + [BitAutoData] + public async Task GetById_VersionNotFound_Throws( + SutProvider sutProvider, + Guid versionId) + { + sutProvider.GetDependency().GetByIdAsync(versionId).Returns((SecretVersion?)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetByIdAsync(versionId)); + } + + [Theory] + [BitAutoData] + public async Task GetById_Success( + SutProvider sutProvider, + SecretVersion version, + Secret secret, + Guid userId) + { + version.SecretId = secret.Id; + sutProvider.GetDependency().GetByIdAsync(version.Id).Returns(version); + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(false); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, false)); + + var result = await sutProvider.Sut.GetByIdAsync(version.Id); + + Assert.Equal(version.Id, result.Id); + Assert.Equal(version.SecretId, result.SecretId); + } + + [Theory] + [BitAutoData] + public async Task RestoreVersion_NoWriteAccess_Throws( + SutProvider sutProvider, + Secret secret, + SecretVersion version, + RestoreSecretVersionRequestModel request, + Guid userId) + { + version.SecretId = secret.Id; + request.VersionId = version.Id; + + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(false); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, false)); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.RestoreVersionAsync(secret.Id, request)); + } + + [Theory] + [BitAutoData] + public async Task RestoreVersion_VersionNotFound_Throws( + SutProvider sutProvider, + Secret secret, + RestoreSecretVersionRequestModel request, + Guid userId) + { + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, true)); + sutProvider.GetDependency().GetByIdAsync(request.VersionId).Returns((SecretVersion?)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.RestoreVersionAsync(secret.Id, request)); + } + + [Theory] + [BitAutoData] + public async Task RestoreVersion_VersionBelongsToDifferentSecret_Throws( + SutProvider sutProvider, + Secret secret, + SecretVersion version, + RestoreSecretVersionRequestModel request, + Guid userId) + { + version.SecretId = Guid.NewGuid(); // Different secret + request.VersionId = version.Id; + + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, true)); + sutProvider.GetDependency().GetByIdAsync(request.VersionId).Returns(version); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.RestoreVersionAsync(secret.Id, request)); + } + + [Theory] + [BitAutoData] + public async Task RestoreVersion_Success( + SutProvider sutProvider, + Secret secret, + SecretVersion version, + RestoreSecretVersionRequestModel request, + Guid userId, + OrganizationUser organizationUser) + { + version.SecretId = secret.Id; + request.VersionId = version.Id; + var versionValue = version.Value; + organizationUser.OrganizationId = secret.OrganizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency().GetByIdAsync(secret.Id).Returns(secret); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, true)); + sutProvider.GetDependency().GetByIdAsync(request.VersionId).Returns(version); + sutProvider.GetDependency() + .GetByOrganizationAsync(secret.OrganizationId, userId).Returns(organizationUser); + sutProvider.GetDependency().UpdateAsync(Arg.Any()).Returns(x => x.Arg()); + + var result = await sutProvider.Sut.RestoreVersionAsync(secret.Id, request); + + await sutProvider.GetDependency().Received(1) + .UpdateAsync(Arg.Is(s => s.Value == versionValue)); + } + + [Theory] + [BitAutoData] + public async Task BulkDelete_EmptyIds_Throws( + SutProvider sutProvider) + { + await Assert.ThrowsAsync(() => + sutProvider.Sut.BulkDeleteAsync(new List())); + } + + [Theory] + [BitAutoData] + public async Task BulkDelete_VersionNotFound_Throws( + SutProvider sutProvider, + List ids) + { + sutProvider.GetDependency().GetByIdAsync(ids[0]).Returns((SecretVersion?)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.BulkDeleteAsync(ids)); + } + + [Theory] + [BitAutoData] + public async Task BulkDelete_NoWriteAccess_Throws( + SutProvider sutProvider, + List versions, + Secret secret, + Guid userId) + { + var ids = versions.Select(v => v.Id).ToList(); + foreach (var version in versions) + { + version.SecretId = secret.Id; + sutProvider.GetDependency().GetByIdAsync(version.Id).Returns(version); + } + + sutProvider.GetDependency().GetManyByIds(Arg.Any>()) + .Returns(new List { secret }); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(false); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, false)); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.BulkDeleteAsync(ids)); + } + + [Theory] + [BitAutoData] + public async Task BulkDelete_Success( + SutProvider sutProvider, + List versions, + Secret secret, + Guid userId) + { + var ids = versions.Select(v => v.Id).ToList(); + foreach (var version in versions) + { + version.SecretId = secret.Id; + } + + sutProvider.GetDependency().GetManyByIdsAsync(ids).Returns(versions); + sutProvider.GetDependency().GetManyByIds(Arg.Any>()) + .Returns(new List { secret }); + sutProvider.GetDependency().AccessSecretsManager(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().IdentityClientType.Returns(IdentityClientType.ServiceAccount); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().OrganizationAdmin(secret.OrganizationId).Returns(true); + sutProvider.GetDependency().AccessToSecretAsync(secret.Id, userId, default) + .ReturnsForAnyArgs((true, true)); + + await sutProvider.Sut.BulkDeleteAsync(ids); + + await sutProvider.GetDependency().Received(1) + .DeleteManyByIdAsync(Arg.Is>(x => x.SequenceEqual(ids))); + } +} diff --git a/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs index 83a4229f39..51f61ad7c1 100644 --- a/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/SecretsControllerTests.cs @@ -2,6 +2,7 @@ using Bit.Api.SecretsManager.Controllers; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.Test.SecretsManager.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -244,6 +245,7 @@ public class SecretsControllerTests { data = SetupSecretUpdateRequest(data); SetControllerUser(sutProvider, new Guid()); + sutProvider.GetDependency().IdentityClientType.Returns(IdentityClientType.ServiceAccount); sutProvider.GetDependency() .AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any>()).ReturnsForAnyArgs(AuthorizationResult.Success()); @@ -602,6 +604,7 @@ public class SecretsControllerTests { data = SetupSecretUpdateRequest(data, true); + sutProvider.GetDependency().IdentityClientType.Returns(IdentityClientType.ServiceAccount); sutProvider.GetDependency() .AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any>()).Returns(AuthorizationResult.Success()); From ed7a234eeb53636529af3f8aa0176cc3b0f3aa61 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 3 Dec 2025 19:19:46 +0100 Subject: [PATCH 31/89] Add data recovery tool flag (#6659) --- .../Controllers/AccountsKeyManagementController.cs | 2 +- src/Core/Constants.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 7968970048..5feda856d5 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -80,7 +80,7 @@ public class AccountsKeyManagementController : Controller [HttpPost("key-management/regenerate-keys")] public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) { - if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) + if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration) && !_featureService.IsEnabled(FeatureFlagKeys.DataRecoveryTool)) { throw new NotFoundException(); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0a26e6f324..ccc3555567 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -212,6 +212,7 @@ public static class FeatureFlagKeys public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; public const string DisableType0Decryption = "pm-25174-disable-type-0-decryption"; public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component"; + public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; From b0f6b22b3d0492094de84bce386f5fa9d105d9f8 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:50:01 -0500 Subject: [PATCH 32/89] chore: update duende license (#6680) --- src/Core/Settings/GlobalSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 3446d1af2a..a1d4af464a 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -483,7 +483,7 @@ public class GlobalSettings : IGlobalSettings public string CertificatePassword { get; set; } public string RedisConnectionString { get; set; } public string CosmosConnectionString { get; set; } - public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug"; + public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZUtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzY1MDY1NjAwLCJleHAiOjE3OTY1MTUyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiOTUxNSIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwiY2xpZW50X2xpbWl0IjowfQ.rWUsq-XBKNwPG7BRKG-vShXHuyHLHJCh0sEWdWT4Rkz4ArIPOAepEp9wNya-hxFKkBTFlPaQ5IKk4wDTvkQkuq1qaI_v6kSCdaP9fvXp0rmh4KcFEffVLB-wAOK2S2Cld5DzdyCoskUUfwNQP7xuLsz2Ydxe_whSRIdv8bsMbvTC3Kl8PYZPZ4MxqW8rSZ_mEuCpSe5-Q40sB7aiu_7YmWLJaKrfBTIqYH-XuzQj36Aemoei0efcntej-gvxovy-5SiSEsGuRZj41rjEZYOuj5KgHihJViO1VDHK6CNtlu2Ks8bkv6G2hO-TkF16Y28ywEG_beLEf_s5dzhbDBDbvA"; /// /// Sliding lifetime of a refresh token in seconds. /// From 655054aa560fb913fca7f55c96ccf96b49f58222 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:57:01 -0500 Subject: [PATCH 33/89] refactor(IdentityTokenResponse): [Auth/PM-3537] Remove deprecated "KeyConnectorUrl" from root of IdentityTokenResponse (#6627) * PM-3537 - Remove "KeyConnectorUrl" from root of IdentityTokenResponse * PM-3537 - CustomTokenRequestValidator.cs - update comment to be accurate --- .../RequestValidators/CustomTokenRequestValidator.cs | 6 +++--- .../Endpoints/IdentityServerSsoTests.cs | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 4d75da92fe..38a4813ecd 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -161,16 +161,16 @@ public class CustomTokenRequestValidator : BaseRequestValidator RunSuccessTestAsync(MemberDecryptionType memberDecryptionType) From d619a4999821971155cfb32e44f002ddfd52d6e3 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:28:01 +0100 Subject: [PATCH 34/89] [PM-28508] Fix No validation occurs for Expiration date on Self Host licenses (#6655) * Fix the license validation bug * resolve the failing test * fix the failing test * Revert changes and Add the ui display fix * remove empty spaces * revert the changes on licensing file * revert changes to the test signup * Revert the org license file changes * revert the empty spaces * revert the empty spaces changes * remove the empty spaces * revert * Remove the duplicate code * Add the expire date fix for premium * Fix the failing test * Fix the lint error --- .../OrganizationResponseModel.cs | 27 +++++++++++ .../Billing/Controllers/AccountsController.cs | 9 ++-- .../Controllers/OrganizationsController.cs | 3 +- .../Response/SubscriptionResponseModel.cs | 45 ++++++++++++++++++- .../Controllers/AccountsControllerTests.cs | 6 ++- 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 89a2d4b51f..9a3543f4bb 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Security.Claims; using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Business; @@ -177,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel } } + public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) : + this(organization, (Plan)null) + { + if (license != null) + { + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = license.Expires; + ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial + ? license.Expires + : license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays)); + } + } + } + public string StorageName { get; set; } public double? StorageGb { get; set; } public BillingCustomerDiscount CustomerDiscount { get; set; } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 075218dd74..506ce13e4e 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -26,7 +26,8 @@ public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IUserAccountKeysQuery userAccountKeysQuery, - IFeatureService featureService) : Controller + IFeatureService featureService, + ILicensingService licensingService) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -97,12 +98,14 @@ public class AccountsController( var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount); } else { var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, null, license, claimsPrincipal); } } else diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 5494c5a90e..6b8061c03c 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -67,7 +67,8 @@ public class OrganizationsController( if (globalSettings.SelfHosted) { var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); - return new OrganizationSubscriptionResponseModel(organization, orgLicense); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense); + return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal); } var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 29a47e160c..32d12aa416 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Constants; +using System.Security.Claims; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel : null; } + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// The claims principal containing cryptographically secure token claims + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false) + : base("subscription") + { + Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; + UpcomingInvoice = subscription?.UpcomingInvoice != null ? + new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; + StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; + StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB + MaxStorageGb = user.MaxStorageGb; + License = license; + + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = License.Expires; + } + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount) + ? new BillingCustomerDiscount(subscription!.CustomerDiscount!) + : null; + } + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs index d84fddd282..0309264096 100644 --- a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -4,6 +4,7 @@ using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Queries.Interfaces; @@ -30,6 +31,7 @@ public class AccountsControllerTests : IDisposable private readonly IPaymentService _paymentService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly ILicensingService _licensingService; private readonly GlobalSettings _globalSettings; private readonly AccountsController _sut; @@ -40,13 +42,15 @@ public class AccountsControllerTests : IDisposable _paymentService = Substitute.For(); _twoFactorIsEnabledQuery = Substitute.For(); _userAccountKeysQuery = Substitute.For(); + _licensingService = Substitute.For(); _globalSettings = new GlobalSettings { SelfHosted = false }; _sut = new AccountsController( _userService, _twoFactorIsEnabledQuery, _userAccountKeysQuery, - _featureService + _featureService, + _licensingService ); } From d88fff426220d7c734392fbca530eb348e3fc947 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Thu, 4 Dec 2025 11:30:26 -0500 Subject: [PATCH 35/89] [PM-21742] Fix MJML validation error. (#6687) --- src/Core/MailTemplates/Mjml/build.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/build.js b/src/Core/MailTemplates/Mjml/build.js index db8a7fe433..4e3eaef449 100644 --- a/src/Core/MailTemplates/Mjml/build.js +++ b/src/Core/MailTemplates/Mjml/build.js @@ -41,8 +41,10 @@ if (!fs.existsSync(config.outputDir)) { } } -// Find all MJML files with absolute path -const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`); +// Find all MJML files with absolute paths, excluding components directories +const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`, { + ignore: ['**/components/**'] +}); console.log(`\n[INFO] Found ${mjmlFiles.length} MJML file(s) to compile...`); From 101ff9d6ed09d5c5dcd083b8450a88e0360e3adc Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:10:13 -0600 Subject: [PATCH 36/89] [PM-28423] Add `latest_invoice` expansion / logging to `SubscriptionCancellationJob` (#6603) * Added latest_invoice expansion / logging to cancellation job * Run dotnet format * Claude feedback * Run dotnet format --- .../Jobs/SubscriptionCancellationJob.cs | 30 +- src/Core/Billing/Constants/StripeConstants.cs | 6 + .../Jobs/SubscriptionCancellationJobTests.cs | 388 ++++++++++++++++++ 3 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index 69b7bc876d..60b671df3d 100644 --- a/src/Billing/Jobs/SubscriptionCancellationJob.cs +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -1,16 +1,17 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Billing.Services; +using Bit.Billing.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Repositories; using Quartz; using Stripe; namespace Bit.Billing.Jobs; +using static StripeConstants; + public class SubscriptionCancellationJob( IStripeFacade stripeFacade, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + ILogger logger) : IJob { public async Task Execute(IJobExecutionContext context) @@ -21,20 +22,31 @@ public class SubscriptionCancellationJob( var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null || organization.Enabled) { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because organization is either null or enabled", nameof(SubscriptionCancellationJob), subscriptionId); // Organization was deleted or re-enabled by CS, skip cancellation return; } - var subscription = await stripeFacade.GetSubscription(subscriptionId); - if (subscription?.Status != "unpaid" || - subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create")) + var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions { + Expand = ["latest_invoice"] + }); + + if (subscription is not + { + Status: SubscriptionStatus.Unpaid, + LatestInvoice: { BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle } + }) + { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because subscription is not unpaid or does not have a cancellable billing reason", nameof(SubscriptionCancellationJob), subscriptionId); return; } // Cancel the subscription await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId); + // Void any open invoices var options = new InvoiceListOptions { @@ -46,6 +58,7 @@ public class SubscriptionCancellationJob( foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } while (invoices.HasMore) @@ -55,6 +68,7 @@ public class SubscriptionCancellationJob( foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index c062351a91..dc128127ae 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -12,6 +12,12 @@ public static class StripeConstants public const string UnrecognizedLocation = "unrecognized_location"; } + public static class BillingReasons + { + public const string SubscriptionCreate = "subscription_create"; + public const string SubscriptionCycle = "subscription_cycle"; + } + public static class CollectionMethod { public const string ChargeAutomatically = "charge_automatically"; diff --git a/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs b/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs new file mode 100644 index 0000000000..03bf24f7ff --- /dev/null +++ b/test/Billing.Test/Jobs/SubscriptionCancellationJobTests.cs @@ -0,0 +1,388 @@ +using Bit.Billing.Constants; +using Bit.Billing.Jobs; +using Bit.Billing.Services; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Quartz; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Jobs; + +public class SubscriptionCancellationJobTests +{ + private readonly IStripeFacade _stripeFacade; + private readonly IOrganizationRepository _organizationRepository; + private readonly SubscriptionCancellationJob _sut; + + public SubscriptionCancellationJobTests() + { + _stripeFacade = Substitute.For(); + _organizationRepository = Substitute.For(); + _sut = new SubscriptionCancellationJob(_stripeFacade, _organizationRepository, Substitute.For>()); + } + + [Fact] + public async Task Execute_OrganizationIsNull_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + _organizationRepository.GetByIdAsync(organizationId).Returns((Organization)null); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Execute_OrganizationIsEnabled_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = true + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceiveWithAnyArgs().CancelSubscription(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Execute_SubscriptionStatusIsNotUnpaid_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Active, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any()); + } + + [Fact] + public async Task Execute_BillingReasonIsInvalid_SkipsCancellation() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "manual" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceive().CancelSubscription(subscriptionId, Arg.Any()); + } + + [Fact] + public async Task Execute_ValidConditions_CancelsSubscriptionAndVoidsInvoices() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = + [ + new Invoice { Id = "inv_1" }, + new Invoice { Id = "inv_2" } + ], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1).VoidInvoice("inv_1"); + await _stripeFacade.Received(1).VoidInvoice("inv_2"); + } + + [Fact] + public async Task Execute_WithSubscriptionCreateBillingReason_CancelsSubscription() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_create" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = [], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + } + + [Fact] + public async Task Execute_NoOpenInvoices_CancelsSubscriptionOnly() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = [], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.DidNotReceiveWithAnyArgs().VoidInvoice(Arg.Any()); + } + + [Fact] + public async Task Execute_WithPagination_VoidsAllInvoices() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + // First page of invoices + var firstPage = new StripeList + { + Data = + [ + new Invoice { Id = "inv_1" }, + new Invoice { Id = "inv_2" } + ], + HasMore = true + }; + + // Second page of invoices + var secondPage = new StripeList + { + Data = + [ + new Invoice { Id = "inv_3" }, + new Invoice { Id = "inv_4" } + ], + HasMore = false + }; + + _stripeFacade.ListInvoices(Arg.Is(o => o.StartingAfter == null)) + .Returns(firstPage); + _stripeFacade.ListInvoices(Arg.Is(o => o.StartingAfter == "inv_2")) + .Returns(secondPage); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1).VoidInvoice("inv_1"); + await _stripeFacade.Received(1).VoidInvoice("inv_2"); + await _stripeFacade.Received(1).VoidInvoice("inv_3"); + await _stripeFacade.Received(1).VoidInvoice("inv_4"); + await _stripeFacade.Received(2).ListInvoices(Arg.Any()); + } + + [Fact] + public async Task Execute_ListInvoicesCalledWithCorrectOptions() + { + // Arrange + const string subscriptionId = "sub_123"; + var organizationId = Guid.NewGuid(); + var context = CreateJobExecutionContext(subscriptionId, organizationId); + + var organization = new Organization + { + Id = organizationId, + Enabled = false + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Unpaid, + LatestInvoice = new Invoice + { + BillingReason = "subscription_cycle" + } + }; + _stripeFacade.GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))) + .Returns(subscription); + + var invoices = new StripeList + { + Data = [], + HasMore = false + }; + _stripeFacade.ListInvoices(Arg.Any()).Returns(invoices); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).GetSubscription(subscriptionId, Arg.Is(o => o.Expand.Contains("latest_invoice"))); + await _stripeFacade.Received(1).ListInvoices(Arg.Is(o => + o.Status == "open" && + o.Subscription == subscriptionId && + o.Limit == 100)); + } + + private static IJobExecutionContext CreateJobExecutionContext(string subscriptionId, Guid organizationId) + { + var context = Substitute.For(); + var jobDataMap = new JobDataMap + { + { "subscriptionId", subscriptionId }, + { "organizationId", organizationId.ToString() } + }; + context.MergedJobDataMap.Returns(jobDataMap); + return context; + } +} From 3605b4d2ffbab735779b6f202cf8a7a7ee0bb45f Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:37:51 -0500 Subject: [PATCH 37/89] Upgrade ExtendedCache to support non-Redis distributed cache (#6682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Upgrade ExtendedCache to support non-Redis distributed cache * Update CACHING.md to use UseSharedDistributedCache setting Updated documentation to reflect the setting rename from UseSharedRedisCache to UseSharedDistributedCache in the ExtendedCache configuration examples. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Matt Bishop --- src/Core/Settings/GlobalSettings.cs | 2 +- src/Core/Utilities/CACHING.md | 2 +- ...xtendedCacheServiceCollectionExtensions.cs | 47 +++-- ...edCacheServiceCollectionExtensionsTests.cs | 187 +++++++++++++++++- 4 files changed, 218 insertions(+), 20 deletions(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index a1d4af464a..b0d7da05a2 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -732,7 +732,7 @@ public class GlobalSettings : IGlobalSettings public class ExtendedCacheSettings { public bool EnableDistributedCache { get; set; } = true; - public bool UseSharedRedisCache { get; set; } = true; + public bool UseSharedDistributedCache { get; set; } = true; public IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings(); public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30); public bool IsFailSafeEnabled { get; set; } = true; diff --git a/src/Core/Utilities/CACHING.md b/src/Core/Utilities/CACHING.md index d838896cbf..d80e629bdd 100644 --- a/src/Core/Utilities/CACHING.md +++ b/src/Core/Utilities/CACHING.md @@ -140,7 +140,7 @@ services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.E // Option 4: Isolated Redis for specialized features services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379,ssl=false" diff --git a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs index a928240fd7..f287f64e54 100644 --- a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs +++ b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs @@ -18,9 +18,12 @@ public static class ExtendedCacheServiceCollectionExtensions /// Adds a new, named Fusion Cache to the service /// collection. If an existing cache of the same name is found, it will do nothing.
///
- /// Note: When re-using the existing Redis cache, it is expected to call this method after calling - /// services.AddDistributedCache(globalSettings)
This ensures that DI correctly finds, - /// configures, and re-uses all the shared Redis architecture. + /// Note: When re-using an existing distributed cache, it is expected to call this method after calling + /// services.AddDistributedCache(globalSettings)
This ensures that DI correctly finds + /// and re-uses the shared distributed cache infrastructure.
+ ///
+ /// Backplane: Cross-instance cache invalidation is only available when using Redis. + /// Non-Redis distributed caches operate with eventual consistency across multiple instances. ///
public static IServiceCollection AddExtendedCache( this IServiceCollection services, @@ -72,12 +75,21 @@ public static class ExtendedCacheServiceCollectionExtensions if (!settings.EnableDistributedCache) return services; - if (settings.UseSharedRedisCache) + if (settings.UseSharedDistributedCache) { - // Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) - if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString)) + { + // Using Shared Non-Redis Distributed Cache: + // 1. Assume IDistributedCache is already registered (e.g., Cosmos, SQL Server) + // 2. Backplane not supported (Redis-only feature, requires pub/sub) + + fusionCacheBuilder + .TryWithRegisteredDistributedCache(); + return services; + } + + // Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) services.TryAddSingleton(sp => CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString)); @@ -92,13 +104,13 @@ public static class ExtendedCacheServiceCollectionExtensions }); services.TryAddSingleton(sp => + { + var mux = sp.GetRequiredService(); + return new RedisBackplane(new RedisBackplaneOptions { - var mux = sp.GetRequiredService(); - return new RedisBackplane(new RedisBackplaneOptions - { - ConnectionMultiplexerFactory = () => Task.FromResult(mux) - }); + ConnectionMultiplexerFactory = () => Task.FromResult(mux) }); + }); fusionCacheBuilder .WithRegisteredDistributedCache() @@ -107,10 +119,21 @@ public static class ExtendedCacheServiceCollectionExtensions return services; } - // Using keyed Redis / Distributed Cache. Create all pieces as keyed services. + // Using keyed Distributed Cache. Create/Reuse all pieces as keyed services. if (!CoreHelpers.SettingHasValue(settings.Redis.ConnectionString)) + { + // Using Keyed Non-Redis Distributed Cache: + // 1. Assume IDistributedCache (e.g., Cosmos, SQL Server) is already registered with cacheName as key + // 2. Backplane not supported (Redis-only feature, requires pub/sub) + + fusionCacheBuilder + .TryWithRegisteredKeyedDistributedCache(serviceKey: cacheName); + return services; + } + + // Using Keyed Redis: TryAdd and reuse all pieces (multiplexer, distributed cache and backplane) services.TryAddKeyedSingleton( cacheName, diff --git a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs index 6f7fa4df06..e2cb9d5d52 100644 --- a/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/Utilities/ExtendedCacheServiceCollectionExtensionsTests.cs @@ -7,6 +7,7 @@ using NSubstitute; using StackExchange.Redis; using Xunit; using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane; namespace Bit.Core.Test.Utilities; @@ -167,7 +168,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests var settings = CreateGlobalSettings(new() { { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" }, - { "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedRedisCache", "true" } + { "GlobalSettings:DistributedCache:DefaultExtendedCache:UseSharedDistributedCache", "true" } }); // Provide a multiplexer (shared) @@ -187,7 +188,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests { var settings = new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "invalid:9999" } }; @@ -242,7 +243,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests { var settings = new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, // No Redis connection string }; @@ -261,13 +262,13 @@ public class ExtendedCacheServiceCollectionExtensionsTests var settingsA = new GlobalSettings.ExtendedCacheSettings { EnableDistributedCache = true, - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } }; var settingsB = new GlobalSettings.ExtendedCacheSettings { EnableDistributedCache = true, - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6380" } }; @@ -294,7 +295,7 @@ public class ExtendedCacheServiceCollectionExtensionsTests var settings = new GlobalSettings.ExtendedCacheSettings { - UseSharedRedisCache = false, + UseSharedDistributedCache = false, Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } }; @@ -306,6 +307,180 @@ public class ExtendedCacheServiceCollectionExtensionsTests Assert.Same(existingCache, resolved); } + [Fact] + public void AddExtendedCache_SharedNonRedisCache_UsesDistributedCacheWithoutBackplane() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = true, + EnableDistributedCache = true, + // No Redis.ConnectionString + }; + + // Register non-Redis distributed cache + _services.AddSingleton(Substitute.For()); + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); // No backplane for non-Redis + } + + [Fact] + public void AddExtendedCache_SharedRedisWithMockedMultiplexer_ReusesExistingMultiplexer() + { + // Override GlobalSettings to include Redis connection string + var globalSettings = CreateGlobalSettings(new() + { + { "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" } + }); + + // Custom settings for this cache + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = true, + EnableDistributedCache = true, + }; + + // Pre-register mocked multiplexer (simulates AddDistributedCache already called) + var mockMultiplexer = Substitute.For(); + _services.AddSingleton(mockMultiplexer); + + _services.AddExtendedCache(_cacheName, globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.True(cache.HasBackplane); + + // Verify same multiplexer was reused (TryAdd didn't replace it) + var resolvedMux = provider.GetRequiredService(); + Assert.Same(mockMultiplexer, resolvedMux); + } + + [Fact] + public void AddExtendedCache_KeyedNonRedisCache_UsesKeyedDistributedCacheWithoutBackplane() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + // No Redis.ConnectionString + }; + + // Register keyed non-Redis distributed cache + _services.AddKeyedSingleton(_cacheName, Substitute.For()); + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + } + + [Fact] + public void AddExtendedCache_KeyedRedisWithConnectionString_CreatesIsolatedInfrastructure() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + Redis = new GlobalSettings.ConnectionStringSettings + { + ConnectionString = "localhost:6379" + } + }; + + // Pre-register mocked keyed multiplexer to avoid connection attempt + _services.AddKeyedSingleton(_cacheName, Substitute.For()); + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.True(cache.HasDistributedCache); + Assert.True(cache.HasBackplane); + + // Verify keyed services exist + var keyedMux = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(keyedMux); + var keyedRedis = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(keyedRedis); + var keyedBackplane = provider.GetRequiredKeyedService(_cacheName); + Assert.NotNull(keyedBackplane); + } + + [Fact] + public void AddExtendedCache_NoDistributedCacheRegistered_WorksWithMemoryOnly() + { + var settings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = true, + EnableDistributedCache = true, + // No Redis connection string, no IDistributedCache registered + // This is technically a misconfiguration, but we handle it without failing + }; + + _services.AddExtendedCache(_cacheName, _globalSettings, settings); + + using var provider = _services.BuildServiceProvider(); + var cache = provider.GetRequiredKeyedService(_cacheName); + + Assert.False(cache.HasDistributedCache); + Assert.False(cache.HasBackplane); + + // Verify L1 memory cache still works + cache.Set("key", "value"); + var result = cache.GetOrDefault("key"); + Assert.Equal("value", result); + } + + [Fact] + public void AddExtendedCache_MultipleKeyedCachesWithDifferentTypes_EachHasCorrectConfig() + { + var redisSettings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" } + }; + + var nonRedisSettings = new GlobalSettings.ExtendedCacheSettings + { + UseSharedDistributedCache = false, + EnableDistributedCache = true, + // No Redis connection string + }; + + // Setup Cache1 (Redis) + _services.AddKeyedSingleton("Cache1", Substitute.For()); + _services.AddExtendedCache("Cache1", _globalSettings, redisSettings); + + // Setup Cache2 (non-Redis) + _services.AddKeyedSingleton("Cache2", Substitute.For()); + _services.AddExtendedCache("Cache2", _globalSettings, nonRedisSettings); + + using var provider = _services.BuildServiceProvider(); + + var cache1 = provider.GetRequiredKeyedService("Cache1"); + var cache2 = provider.GetRequiredKeyedService("Cache2"); + + Assert.True(cache1.HasDistributedCache); + Assert.True(cache1.HasBackplane); + + Assert.True(cache2.HasDistributedCache); + Assert.False(cache2.HasBackplane); + + Assert.NotSame(cache1, cache2); + } + private static GlobalSettings CreateGlobalSettings(Dictionary data) { var config = new ConfigurationBuilder() From 80ee31b4fe8d006abd822e2273f55c5c8f7e3d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:22:00 +0000 Subject: [PATCH 38/89] [PM-25015] Add performance tests for Admin Console endpoints (#6235) * Add GroupsRecipe to manage group creation and user relationships in organizations * Add CollectionsRecipe to manage collection creation and user relationships in organizations * Refactor OrganizationUsersControllerPerformanceTests to enhance performance testing and add new test cases * Add OrganizationDomainRecipe to add verified domains for organizations * Add more tests to OrganizationUsersControllerPerformanceTests and enhance seeding logic for organizations - Updated performance tests to use dynamic domain generation for organization users. - Refactored seeding methods in OrganizationWithUsersRecipe to accept user status and type. - Modified AddToOrganization methods in CollectionsRecipe and GroupsRecipe to return created IDs. - Adjusted DbSeederUtility to align with new seeding method signatures. * Enhance OrganizationSeeder with additional configuration options and update seat calculation in OrganizationWithUsersRecipe to ensure a minimum of 1000 seats. * Add performance tests for Groups, Organizations, Organization Users, and Provider Organizations controllers - Introduced `GroupsControllerPerformanceTests` to validate the performance of the PutGroupAsync method. - Added `OrganizationsControllerPerformanceTests` with multiple tests including DeleteOrganizationAsync, DeleteOrganizationWithTokenAsync, PostStorageAsync, and CreateWithoutPaymentAsync. - Enhanced `OrganizationUsersControllerPerformanceTests` with DeleteSingleUserAccountAsync and InviteUsersAsync methods to test user account deletion and bulk invitations. - Created `ProviderOrganizationsControllerPerformanceTests` to assess the performance of deleting provider organizations. These tests ensure the reliability and efficiency of the respective controller actions under various scenarios. * Refactor GroupsControllerPerformanceTests to use parameterized tests - Renamed `GroupsControllerPerformanceTest` to `GroupsControllerPerformanceTests` for consistency. - Updated `PutGroupAsync` method to use `[Theory]` with `InlineData` for dynamic user and collection counts. - Adjusted organization user and collection seeding logic to utilize the new parameters. - Enhanced logging to provide clearer performance metrics during tests. * Update domain generation in GroupsControllerPerformanceTests for improved test consistency * Remove ProviderOrganizationsControllerPerformanceTests * Refactor performance tests for Groups, Organizations, and Organization Users controllers - Updated method names for clarity and consistency, e.g., `PutGroupAsync` to `UpdateGroup_WithUsersAndCollections`. - Enhanced test documentation with XML comments to describe the purpose of each test. - Improved domain generation logic for consistency across tests. - Adjusted logging to provide detailed performance metrics during test execution. - Renamed several test methods to better reflect their functionality. * Refactor performance tests in Organizations and Organization Users controllers - Updated tests to use parameterized `[Theory]` attributes with `InlineData` for dynamic user, collection, and group counts. - Enhanced logging to include detailed metrics such as user and collection counts during test execution. - Marked several tests as skipped for performance considerations. - Removed unused code and improved organization of test methods for clarity. * Add bulk reinvite users performance test to OrganizationUsersControllerPerformanceTests - Implemented a new performance test for the POST /organizations/{orgId}/users/reinvite endpoint. - Utilized parameterized testing with `[Theory]` and `InlineData` to evaluate performance with varying user counts. - Enhanced logging to capture request duration and response status for better performance insights. - Updated OrganizationSeeder to conditionally set email based on user status during seeding. * Refactor domain generation in performance tests to use OrganizationTestHelpers - Updated domain generation logic in GroupsControllerPerformanceTests, OrganizationsControllerPerformanceTests, and OrganizationUsersControllerPerformanceTests to utilize the new GenerateRandomDomain method from OrganizationTestHelpers. - This change enhances consistency and readability across the tests by centralizing domain generation logic. * Update CollectionsRecipe to have better readability * Update GroupsRecipe to have better readability * Refactor authentication in performance tests to use centralized helper method. This change reduces code duplication across Groups, Organizations, and OrganizationUsers controller tests by implementing the `AuthenticateClientAsync` method in a new `PerformanceTestHelpers` class. * Refactor OrganizationUsersControllerPerformanceTests to filter organization users by OrganizationId. * Refactor CreateOrganizationUser method to improve handling of user status and key assignment based on invitation and confirmation states. * Add XML documentation for CreateOrganizationUser method to clarify user status handling --- .../GroupsControllerPerformanceTests.cs | 63 ++ ...nizationUsersControllerPerformanceTests.cs | 578 +++++++++++++++++- ...OrganizationsControllerPerformanceTests.cs | 163 +++++ .../Helpers/OrganizationTestHelpers.cs | 9 + .../Helpers/PerformanceTestHelpers.cs | 32 + util/DbSeederUtility/Program.cs | 2 +- util/Seeder/Factories/OrganizationSeeder.cs | 46 +- util/Seeder/Recipes/CollectionsRecipe.cs | 122 ++++ util/Seeder/Recipes/GroupsRecipe.cs | 94 +++ .../Recipes/OrganizationDomainRecipe.cs | 25 + .../Recipes/OrganizationWithUsersRecipe.cs | 18 +- 11 files changed, 1124 insertions(+), 28 deletions(-) create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs create mode 100644 test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs create mode 100644 util/Seeder/Recipes/CollectionsRecipe.cs create mode 100644 util/Seeder/Recipes/GroupsRecipe.cs create mode 100644 util/Seeder/Recipes/OrganizationDomainRecipe.cs diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs new file mode 100644 index 0000000000..71c6bf104c --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper) +{ + /// + /// Tests PUT /organizations/{orgId}/groups/{id} + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5)] + //[InlineData(100, 10)] + //[InlineData(1000, 20)] + public async Task UpdateGroup_WithUsersAndCollections(int userCount, int collectionCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0); + + var groupId = groupIds.First(); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var updateRequest = new GroupRequestModel + { + Name = "Updated Group Name", + Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }), + Users = orgUserIds + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/groups/{groupId}", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /organizations/{{orgId}}/groups/{{id}} - Users: {orgUserIds.Count}; Collections: {collectionIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index d77a41f52e..fc64930777 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -1,39 +1,593 @@ using System.Net; -using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Seeder.Recipes; using Xunit; using Xunit.Abstractions; namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; -public class OrganizationUsersControllerPerformanceTest(ITestOutputHelper testOutputHelper) +public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testOutputHelper) { + /// + /// Tests GET /organizations/{orgId}/users?includeCollections=true + /// [Theory(Skip = "Performance test")] - [InlineData(100)] - [InlineData(60000)] - public async Task GetAsync(int seats) + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task GetAllUsers_WithCollections(int seats) { await using var factory = new SqlServerApiApplicationFactory(); var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var seeder = new OrganizationWithUsersRecipe(db); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); - var orgId = seeder.Seed("Org", seats, "large.test"); + var domain = OrganizationTestHelpers.GenerateRandomDomain(); - var tokens = await factory.LoginAsync("admin@large.test", "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); + groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var response = await client.GetAsync($"/organizations/{orgId}/users?includeCollections=true"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var result = await response.Content.ReadAsStringAsync(); - Assert.NotEmpty(result); + stopwatch.Stop(); + testOutputHelper.WriteLine($"GET /users - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + } + + /// + /// Tests GET /organizations/{orgId}/users/mini-details + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task GetAllUsers_MiniDetails(int seats) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); + groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/mini-details"); stopwatch.Stop(); - testOutputHelper.WriteLine($"Seed: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + testOutputHelper.WriteLine($"GET /users/mini-details - Seats: {seats}; Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests GET /organizations/{orgId}/users/{id}?includeGroups=true + /// + [Fact(Skip = "Performance test")] + public async Task GetSingleUser_WithGroups() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); + groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}?includeGroups=true"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"GET /users/{{id}} - Request duration: {stopwatch.ElapsedMilliseconds} ms"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests GET /organizations/{orgId}/users/{id}/reset-password-details + /// + [Fact(Skip = "Performance test")] + public async Task GetResetPasswordDetails_ForSingleUser() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.GetAsync($"/organizations/{orgId}/users/{orgUserId}/reset-password-details"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"GET /users/{{id}}/reset-password-details - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/confirm + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkConfirmUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Accepted); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var acceptedUserIds = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Accepted) + .Select(ou => ou.Id) + .ToList(); + + var confirmRequest = new OrganizationUserBulkConfirmRequestModel + { + Keys = acceptedUserIds.Select(id => new OrganizationUserBulkConfirmRequestModelEntry { Id = id, Key = "test-key-" + id }), + DefaultUserCollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=" + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(confirmRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/confirm", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/confirm - Users: {acceptedUserIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/remove + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRemoveUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRemove = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var removeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRemove }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var requestContent = new StringContent(JsonSerializer.Serialize(removeRequest), Encoding.UTF8, "application/json"); + + var response = await client.PostAsync($"/organizations/{orgId}/users/remove", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/remove - Users: {usersToRemove.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/revoke + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRevokeUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Confirmed); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRevoke = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var revokeRequest = new OrganizationUserBulkRequestModel { Ids = usersToRevoke }; + + var requestContent = new StringContent(JsonSerializer.Serialize(revokeRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/revoke", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/revoke - Users: {usersToRevoke.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/restore + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkRestoreUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Revoked); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToRestore = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var restoreRequest = new OrganizationUserBulkRequestModel { Ids = usersToRestore }; + + var requestContent = new StringContent(JsonSerializer.Serialize(restoreRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/restore", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/restore - Users: {usersToRestore.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/delete-account + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkDeleteAccounts(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var domainSeeder = new OrganizationDomainRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Confirmed); + + domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToDelete = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var deleteRequest = new OrganizationUserBulkRequestModel { Ids = usersToDelete }; + + var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/delete-account", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/delete-account - Users: {usersToDelete.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/{id} + /// + [Fact(Skip = "Performance test")] + public async Task UpdateSingleUser_WithCollectionsAndGroups() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0); + var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var userToUpdate = db.OrganizationUsers + .FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User); + + var updateRequest = new OrganizationUserUpdateRequestModel + { + Type = OrganizationUserType.Custom, + Collections = collectionIds.Select(c => new SelectionReadOnlyRequestModel { Id = c, ReadOnly = false, HidePasswords = false, Manage = false }), + Groups = groupIds, + AccessSecretsManager = false, + Permissions = new Permissions { AccessEventLogs = true } + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/{userToUpdate.Id}", + new StringContent(JsonSerializer.Serialize(updateRequest), Encoding.UTF8, "application/json")); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/{{id}} - Collections: {collectionIds.Count}; Groups: {groupIds.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests PUT /organizations/{orgId}/users/enable-secrets-manager + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkEnableSecretsManager(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToEnable = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User) + .Select(ou => ou.Id) + .ToList(); + + var enableRequest = new OrganizationUserBulkRequestModel { Ids = usersToEnable }; + + var requestContent = new StringContent(JsonSerializer.Serialize(enableRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PutAsync($"/organizations/{orgId}/users/enable-secrets-manager", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"PUT /users/enable-secrets-manager - Users: {usersToEnable.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); + } + + /// + /// Tests DELETE /organizations/{orgId}/users/{id}/delete-account + /// + [Fact(Skip = "Performance test")] + public async Task DeleteSingleUserAccount_FromVerifiedDomain() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var domainSeeder = new OrganizationDomainRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: 2, + usersStatus: OrganizationUserStatusType.Confirmed); + + domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var userToDelete = db.OrganizationUsers + .FirstOrDefault(ou => ou.OrganizationId == orgId && ou.Type == OrganizationUserType.User); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.DeleteAsync($"/organizations/{orgId}/users/{userToDelete.Id}/delete-account"); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"DELETE /users/{{id}}/delete-account - Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/invite + /// + [Theory(Skip = "Performance test")] + [InlineData(1)] + //[InlineData(5)] + //[InlineData(20)] + public async Task InviteUsers(int emailCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var emails = Enumerable.Range(0, emailCount).Select(i => $"{i:D4}@{domain}").ToArray(); + var inviteRequest = new OrganizationUserInviteRequestModel + { + Emails = emails, + Type = OrganizationUserType.User, + AccessSecretsManager = false, + Collections = Array.Empty(), + Groups = Array.Empty(), + Permissions = null + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(inviteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/invite", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/invite - Emails: {emails.Length}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{orgId}/users/reinvite + /// + [Theory(Skip = "Performance test")] + [InlineData(10)] + //[InlineData(100)] + //[InlineData(1000)] + public async Task BulkReinviteUsers(int userCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed( + name: "Org", + domain: domain, + users: userCount, + usersStatus: OrganizationUserStatusType.Invited); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var usersToReinvite = db.OrganizationUsers + .Where(ou => ou.OrganizationId == orgId && ou.Status == OrganizationUserStatusType.Invited) + .Select(ou => ou.Id) + .ToList(); + + var reinviteRequest = new OrganizationUserBulkRequestModel { Ids = usersToReinvite }; + + var requestContent = new StringContent(JsonSerializer.Serialize(reinviteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/users/reinvite", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /users/reinvite - Users: {usersToReinvite.Count}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.True(response.IsSuccessStatusCode); } } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs new file mode 100644 index 0000000000..238a9a5d53 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; +using Bit.Core.Tokens; +using Bit.Seeder.Recipes; +using Xunit; +using Xunit.Abstractions; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutputHelper) +{ + /// + /// Tests DELETE /organizations/{id} with password verification + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5, 3)] + //[InlineData(100, 20, 10)] + //[InlineData(1000, 50, 25)] + public async Task DeleteOrganization_WithPasswordVerification(int userCount, int collectionCount, int groupCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var deleteRequest = new SecretVerificationRequestModel + { + MasterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs=" + }; + + var request = new HttpRequestMessage(HttpMethod.Delete, $"/organizations/{orgId}") + { + Content = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json") + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + + var response = await client.SendAsync(request); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"DELETE /organizations/{{id}} - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/{id}/delete-recover-token with token verification + /// + [Theory(Skip = "Performance test")] + [InlineData(10, 5, 3)] + //[InlineData(100, 20, 10)] + //[InlineData(1000, 50, 25)] + public async Task DeleteOrganization_WithTokenVerification(int userCount, int collectionCount, int groupCount) + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var db = factory.GetDatabaseContext(); + var orgSeeder = new OrganizationWithUsersRecipe(db); + var collectionsSeeder = new CollectionsRecipe(db); + var groupsSeeder = new GroupsRecipe(db); + + var domain = OrganizationTestHelpers.GenerateRandomDomain(); + var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); + + var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); + collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); + + var organization = db.Organizations.FirstOrDefault(o => o.Id == orgId); + Assert.NotNull(organization); + + var tokenFactory = factory.GetService>(); + var tokenable = new OrgDeleteTokenable(organization, 24); + var token = tokenFactory.Protect(tokenable); + + var deleteRequest = new OrganizationVerifyDeleteRecoverRequestModel + { + Token = token + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(deleteRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync($"/organizations/{orgId}/delete-recover-token", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /organizations/{{id}}/delete-recover-token - Users: {userCount}; Collections: {collectionCount}; Groups: {groupCount}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Tests POST /organizations/create-without-payment + /// + [Fact(Skip = "Performance test")] + public async Task CreateOrganization_WithoutPayment() + { + await using var factory = new SqlServerApiApplicationFactory(); + var client = factory.CreateClient(); + + var email = $"user@{OrganizationTestHelpers.GenerateRandomDomain()}"; + var masterPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="; + + await factory.LoginWithNewAccount(email, masterPasswordHash); + + await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, email, masterPasswordHash); + + var createRequest = new OrganizationNoPaymentCreateRequest + { + Name = "Test Organization", + BusinessName = "Test Business Name", + BillingEmail = email, + PlanType = PlanType.EnterpriseAnnually, + Key = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=", + AdditionalSeats = 1, + AdditionalStorageGb = 1, + UseSecretsManager = true, + AdditionalSmSeats = 1, + AdditionalServiceAccounts = 2, + MaxAutoscaleSeats = 100, + PremiumAccessAddon = false, + CollectionName = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=" + }; + + var requestContent = new StringContent(JsonSerializer.Serialize(createRequest), Encoding.UTF8, "application/json"); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + var response = await client.PostAsync("/organizations/create-without-payment", requestContent); + + stopwatch.Stop(); + + testOutputHelper.WriteLine($"POST /organizations/create-without-payment - AdditionalSeats: {createRequest.AdditionalSeats}; AdditionalStorageGb: {createRequest.AdditionalStorageGb}; AdditionalSmSeats: {createRequest.AdditionalSmSeats}; AdditionalServiceAccounts: {createRequest.AdditionalServiceAccounts}; MaxAutoscaleSeats: {createRequest.MaxAutoscaleSeats}; Request duration: {stopwatch.ElapsedMilliseconds} ms; Status: {response.StatusCode}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index c23ebff736..bcde370b24 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -192,6 +192,15 @@ public static class OrganizationTestHelpers await policyRepository.CreateAsync(policy); } + /// + /// Generates a unique random domain name for testing purposes. + /// + /// A domain string like "a1b2c3d4.com" + public static string GenerateRandomDomain() + { + return $"{Guid.NewGuid().ToString("N").Substring(0, 8)}.com"; + } + /// /// Creates a user account without a Master Password and adds them as a member to the specified organization. /// diff --git a/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs b/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs new file mode 100644 index 0000000000..ca26266dfa --- /dev/null +++ b/test/Api.IntegrationTest/Helpers/PerformanceTestHelpers.cs @@ -0,0 +1,32 @@ +using System.Net.Http.Headers; +using Bit.Api.IntegrationTest.Factories; + +namespace Bit.Api.IntegrationTest.Helpers; + +/// +/// Helper methods for performance tests to reduce code duplication. +/// +public static class PerformanceTestHelpers +{ + /// + /// Standard password hash used across performance tests. + /// + public const string StandardPasswordHash = "c55hlJ/cfdvTd4awTXUqow6X3cOQCfGwn11o3HblnPs="; + + /// + /// Authenticates an HttpClient with a bearer token for the specified user. + /// + /// The application factory to use for login. + /// The HttpClient to authenticate. + /// The user's email address. + /// The user's master password hash. Defaults to StandardPasswordHash. + public static async Task AuthenticateClientAsync( + SqlServerApiApplicationFactory factory, + HttpClient client, + string email, + string? masterPasswordHash = null) + { + var tokens = await factory.LoginAsync(email, masterPasswordHash ?? StandardPasswordHash); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token); + } +} diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 2d75b31934..0b41c1a692 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -34,6 +34,6 @@ public class Program var db = scopedServices.GetRequiredService(); var recipe = new OrganizationWithUsersRecipe(db); - recipe.Seed(name, users, domain); + recipe.Seed(name: name, domain: domain, users: users); } } diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index f6f05d9525..012661501f 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -17,7 +17,31 @@ public class OrganizationSeeder Plan = "Enterprise (Annually)", PlanType = PlanType.EnterpriseAnnually, Seats = seats, - + UseCustomPermissions = true, + UseOrganizationDomains = true, + UseSecretsManager = true, + UseGroups = true, + UseDirectory = true, + UseEvents = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + UseResetPassword = true, + UsePasswordManager = true, + UseAutomaticUserConfirmation = true, + SelfHost = true, + UsersGetPremium = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + AllowAdminAccessToAllCollectionItems = true, + UseRiskInsights = true, + UseAdminSponsoredFamilies = true, + SyncSeats = true, + Status = OrganizationStatusType.Created, + //GatewayCustomerId = "example-customer-id", + //GatewaySubscriptionId = "example-subscription-id", + MaxStorageGb = 10, // Currently hardcoded to the values from https://github.com/bitwarden/sdk-internal/blob/main/crates/bitwarden-core/src/client/test_accounts.rs. // TODO: These should be dynamically generated by the SDK. PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmIJbGMk6eZqVE7UxhZ46Weu2jKciqOiOkSVYtGvs61rfe9AXxtLaaZEKN4d4DmkZcF6dna2eXNxZmb7U4pwlttye8ksqISe6IUAZQox7auBpjopdCEPhKRg3BD/u8ks9UxSxgWe+fpebjt6gd5hsl1/5HOObn7SeU6EEU04cp3/eH7a4OTdXxB8oN62HGV9kM/ubM1goILgjoSJDbihMK0eb7b8hPHwcA/YOgKKiu/N3FighccdSMD5Pk+HfjacsFNZQa2EsqW09IvvSZ+iL6HQeZ1vwc/6TO1J7EOfJZFQcjoEL9LVI693efYoMZSmrPEWziZ4PvwpOOGo6OObyMQIDAQAB", @@ -28,17 +52,25 @@ public class OrganizationSeeder public static class OrgnaizationExtensions { - public static OrganizationUser CreateOrganizationUser(this Organization organization, User user) + /// + /// Creates an OrganizationUser with fields populated based on status. + /// For Invited status, only user.Email is used. For other statuses, user.Id is used. + /// + public static OrganizationUser CreateOrganizationUser( + this Organization organization, User user, OrganizationUserType type, OrganizationUserStatusType status) { + var isInvited = status == OrganizationUserStatusType.Invited; + var isConfirmed = status == OrganizationUserStatusType.Confirmed || status == OrganizationUserStatusType.Revoked; + return new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, - UserId = user.Id, - - Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", - Type = OrganizationUserType.Admin, - Status = OrganizationUserStatusType.Confirmed + UserId = isInvited ? null : user.Id, + Email = isInvited ? user.Email : null, + Key = isConfirmed ? "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==" : null, + Type = type, + Status = status }; } diff --git a/util/Seeder/Recipes/CollectionsRecipe.cs b/util/Seeder/Recipes/CollectionsRecipe.cs new file mode 100644 index 0000000000..e0f9057418 --- /dev/null +++ b/util/Seeder/Recipes/CollectionsRecipe.cs @@ -0,0 +1,122 @@ +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Repositories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class CollectionsRecipe(DatabaseContext db) +{ + /// + /// Adds collections to an organization and creates relationships between users and collections. + /// + /// The ID of the organization to add collections to. + /// The number of collections to add. + /// The IDs of the users to create relationships with. + /// The maximum number of users to create relationships with. + public List AddToOrganization(Guid organizationId, int collections, List organizationUserIds, int maxUsersWithRelationships = 1000) + { + var collectionList = CreateAndSaveCollections(organizationId, collections); + + if (collectionList.Any()) + { + CreateAndSaveCollectionUserRelationships(collectionList, organizationUserIds, maxUsersWithRelationships); + } + + return collectionList.Select(c => c.Id).ToList(); + } + + private List CreateAndSaveCollections(Guid organizationId, int count) + { + var collectionList = new List(); + + for (var i = 0; i < count; i++) + { + collectionList.Add(new Core.Entities.Collection + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = $"Collection {i + 1}", + Type = CollectionType.SharedCollection, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }); + } + + if (collectionList.Any()) + { + db.BulkCopy(collectionList); + } + + return collectionList; + } + + private void CreateAndSaveCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var collectionUsers = BuildCollectionUserRelationships(collections, organizationUserIds, maxUsersWithRelationships); + + if (collectionUsers.Any()) + { + db.BulkCopy(collectionUsers); + } + } + + /// + /// Creates user-to-collection relationships with varied assignment patterns for realistic test data. + /// Each user gets 1-3 collections based on a rotating pattern. + /// + private List BuildCollectionUserRelationships( + List collections, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var collectionUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var userCollectionAssignments = CreateCollectionAssignmentsForUser(collections, orgUserId, i); + collectionUsers.AddRange(userCollectionAssignments); + } + + return collectionUsers; + } + + /// + /// Assigns collections to a user with varying permissions. + /// Pattern: 1-3 collections per user (cycles: 1, 2, 3, 1, 2, 3...). + /// First collection has Manage rights, subsequent ones are ReadOnly. + /// + private List CreateCollectionAssignmentsForUser( + List collections, + Guid organizationUserId, + int userIndex) + { + var assignments = new List(); + var userCollectionCount = (userIndex % 3) + 1; // Cycles through 1, 2, or 3 collections + + for (var j = 0; j < userCollectionCount; j++) + { + var collectionIndex = (userIndex + j) % collections.Count; // Distribute across available collections + assignments.Add(new Core.Entities.CollectionUser + { + CollectionId = collections[collectionIndex].Id, + OrganizationUserId = organizationUserId, + ReadOnly = j > 0, // First assignment gets write access + HidePasswords = false, + Manage = j == 0 // First assignment gets manage permissions + }); + } + + return assignments; + } +} diff --git a/util/Seeder/Recipes/GroupsRecipe.cs b/util/Seeder/Recipes/GroupsRecipe.cs new file mode 100644 index 0000000000..3c8156d921 --- /dev/null +++ b/util/Seeder/Recipes/GroupsRecipe.cs @@ -0,0 +1,94 @@ +using Bit.Core.Utilities; +using Bit.Infrastructure.EntityFramework.Repositories; +using LinqToDB.EntityFrameworkCore; + +namespace Bit.Seeder.Recipes; + +public class GroupsRecipe(DatabaseContext db) +{ + /// + /// Adds groups to an organization and creates relationships between users and groups. + /// + /// The ID of the organization to add groups to. + /// The number of groups to add. + /// The IDs of the users to create relationships with. + /// The maximum number of users to create relationships with. + public List AddToOrganization(Guid organizationId, int groups, List organizationUserIds, int maxUsersWithRelationships = 1000) + { + var groupList = CreateAndSaveGroups(organizationId, groups); + + if (groupList.Any()) + { + CreateAndSaveGroupUserRelationships(groupList, organizationUserIds, maxUsersWithRelationships); + } + + return groupList.Select(g => g.Id).ToList(); + } + + private List CreateAndSaveGroups(Guid organizationId, int count) + { + var groupList = new List(); + + for (var i = 0; i < count; i++) + { + groupList.Add(new Core.AdminConsole.Entities.Group + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organizationId, + Name = $"Group {i + 1}" + }); + } + + if (groupList.Any()) + { + db.BulkCopy(groupList); + } + + return groupList; + } + + private void CreateAndSaveGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + if (!organizationUserIds.Any() || maxUsersWithRelationships <= 0) + { + return; + } + + var groupUsers = BuildGroupUserRelationships(groups, organizationUserIds, maxUsersWithRelationships); + + if (groupUsers.Any()) + { + db.BulkCopy(groupUsers); + } + } + + /// + /// Creates user-to-group relationships with distributed assignment patterns for realistic test data. + /// Each user is assigned to one group, distributed evenly across available groups. + /// + private List BuildGroupUserRelationships( + List groups, + List organizationUserIds, + int maxUsersWithRelationships) + { + var maxRelationships = Math.Min(organizationUserIds.Count, maxUsersWithRelationships); + var groupUsers = new List(); + + for (var i = 0; i < maxRelationships; i++) + { + var orgUserId = organizationUserIds[i]; + var groupIndex = i % groups.Count; // Round-robin distribution across groups + + groupUsers.Add(new Core.AdminConsole.Entities.GroupUser + { + GroupId = groups[groupIndex].Id, + OrganizationUserId = orgUserId + }); + } + + return groupUsers; + } +} diff --git a/util/Seeder/Recipes/OrganizationDomainRecipe.cs b/util/Seeder/Recipes/OrganizationDomainRecipe.cs new file mode 100644 index 0000000000..b62dd5115e --- /dev/null +++ b/util/Seeder/Recipes/OrganizationDomainRecipe.cs @@ -0,0 +1,25 @@ +using Bit.Infrastructure.EntityFramework.Models; +using Bit.Infrastructure.EntityFramework.Repositories; + +namespace Bit.Seeder.Recipes; + +public class OrganizationDomainRecipe(DatabaseContext db) +{ + public void AddVerifiedDomainToOrganization(Guid organizationId, string domainName) + { + var domain = new OrganizationDomain + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + DomainName = domainName, + Txt = Guid.NewGuid().ToString("N"), + CreationDate = DateTime.UtcNow, + }; + + domain.SetVerifiedDate(); + domain.SetLastCheckedDate(); + + db.Add(domain); + db.SaveChanges(); + } +} diff --git a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs index fb06c091ae..7678c3a9ce 100644 --- a/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs +++ b/util/Seeder/Recipes/OrganizationWithUsersRecipe.cs @@ -1,4 +1,5 @@ -using Bit.Infrastructure.EntityFramework.Models; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Seeder.Factories; using LinqToDB.EntityFrameworkCore; @@ -7,11 +8,12 @@ namespace Bit.Seeder.Recipes; public class OrganizationWithUsersRecipe(DatabaseContext db) { - public Guid Seed(string name, int users, string domain) + public Guid Seed(string name, string domain, int users, OrganizationUserStatusType usersStatus = OrganizationUserStatusType.Confirmed) { - var organization = OrganizationSeeder.CreateEnterprise(name, domain, users); - var user = UserSeeder.CreateUser($"admin@{domain}"); - var orgUser = organization.CreateOrganizationUser(user); + var seats = Math.Max(users + 1, 1000); + var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats); + var ownerUser = UserSeeder.CreateUser($"owner@{domain}"); + var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed); var additionalUsers = new List(); var additionalOrgUsers = new List(); @@ -19,12 +21,12 @@ public class OrganizationWithUsersRecipe(DatabaseContext db) { var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}"); additionalUsers.Add(additionalUser); - additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser)); + additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus)); } db.Add(organization); - db.Add(user); - db.Add(orgUser); + db.Add(ownerUser); + db.Add(ownerOrgUser); db.SaveChanges(); From 18a8829476230649e5b981a500b737c56a5ae7be Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 5 Dec 2025 08:28:42 -0600 Subject: [PATCH 39/89] [PM-26377] Correcting Auto Confirm Handler Provider Check (#6681) * Fixed bug where providers weren't being checked correctly in auto confirm handler. --- ...maticUserConfirmationPolicyEventHandler.cs | 75 ++--- .../Repositories/IProviderUserRepository.cs | 1 + .../Repositories/ProviderUserRepository.cs | 12 + .../Repositories/ProviderUserRepository.cs | 14 + .../ProviderUser_ReadManyByManyUserIds.sql | 13 + ...UserConfirmationPolicyEventHandlerTests.cs | 310 +++--------------- .../ProviderUserRepositoryTests.cs | 282 ++++++++++++++++ ...-12-03_00_ProviderUserGetManyByUserIds.sql | 13 + 8 files changed, 398 insertions(+), 322 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql create mode 100644 util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index c0d302df02..86c94147f4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -17,26 +18,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; ///
  • All organization users are compliant with the Single organization policy
  • ///
  • No provider users exist
  • /// -/// -/// This class also performs side effects when the policy is being enabled or disabled. They are: -///
      -///
    • Sets the UseAutomaticUserConfirmation organization feature to match the policy update
    • -///
    /// public class AutomaticUserConfirmationPolicyEventHandler( IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository, - IPolicyRepository policyRepository, - IOrganizationRepository organizationRepository, - TimeProvider timeProvider) - : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent + IProviderUserRepository providerUserRepository) + : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent { public PolicyType Type => PolicyType.AutomaticUserConfirmation; - public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => - await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); - - private const string _singleOrgPolicyNotEnabledErrorMessage = - "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; private const string _usersNotCompliantWithSingleOrgErrorMessage = "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; @@ -61,27 +49,20 @@ public class AutomaticUserConfirmationPolicyEventHandler( public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); - public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); - - if (organization is not null) - { - organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; - organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; - await organizationRepository.UpsertAsync(organization); - } - } + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => + Task.CompletedTask; private async Task ValidateEnablingPolicyAsync(Guid organizationId) { - var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + + var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers); if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) { return singleOrgValidationError; } - var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers); if (!string.IsNullOrWhiteSpace(providerValidationError)) { return providerValidationError; @@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler( return string.Empty; } - private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, + ICollection organizationUsers) { - var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); - if (singleOrgPolicy is not { Enabled: true }) - { - return _singleOrgPolicyNotEnabledErrorMessage; - } - - return await ValidateUserComplianceWithSingleOrgAsync(organizationId); - } - - private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) - { - var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) - .Where(ou => ou.Status != OrganizationUserStatusType.Invited && - ou.Status != OrganizationUserStatusType.Revoked && - ou.UserId.HasValue) - .ToList(); - - if (organizationUsers.Count == 0) - { - return string.Empty; - } - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( organizationUsers.Select(ou => ou.UserId!.Value))) - .Any(uo => uo.OrganizationId != organizationId && - uo.Status != OrganizationUserStatusType.Invited); + .Any(uo => uo.OrganizationId != organizationId + && uo.Status != OrganizationUserStatusType.Invited); return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; } - private async Task ValidateNoProviderUsersAsync(Guid organizationId) + private async Task ValidateNoProviderUsersAsync(ICollection organizationUsers) { - var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + var userIds = organizationUsers.Where(x => x.UserId is not null) + .Select(x => x.UserId!.Value); - return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0 + ? _providerUsersExistErrorMessage + : string.Empty; } } diff --git a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs index 7bc4125778..0a640b7530 100644 --- a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs @@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository Task GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers); Task> GetManyAsync(IEnumerable ids); Task> GetManyByUserAsync(Guid userId); + Task> GetManyByManyUsersAsync(IEnumerable userIds); Task GetByProviderUserAsync(Guid providerId, Guid userId); Task> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null); Task> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs index 467857612f..c05ff040e5 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/ProviderUserRepository.cs @@ -61,6 +61,18 @@ public class ProviderUserRepository : Repository, IProviderU } } + public async Task> GetManyByManyUsersAsync(IEnumerable userIds) + { + await using var connection = new SqlConnection(ConnectionString); + + var results = await connection.QueryAsync( + "[dbo].[ProviderUser_ReadManyByManyUserIds]", + new { UserIds = userIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task GetByProviderUserAsync(Guid providerId, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs index 5474e3e217..8f9a38f9b6 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs @@ -96,6 +96,20 @@ public class ProviderUserRepository : return await query.ToArrayAsync(); } } + + public async Task> GetManyByManyUsersAsync(IEnumerable userIds) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + + var dbContext = GetDatabaseContext(scope); + + var query = from pu in dbContext.ProviderUsers + where pu.UserId != null && userIds.Contains(pu.UserId.Value) + select pu; + + return await query.ToArrayAsync(); + } + public async Task GetByProviderUserAsync(Guid providerId, Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql b/src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql new file mode 100644 index 0000000000..4fe8d153e4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/ProviderUser_ReadManyByManyUserIds.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[ProviderUser_ReadManyByManyUserIds] + @UserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + [pu].* + FROM + [dbo].[ProviderUserView] AS [pu] + INNER JOIN + @UserIds [u] ON [u].[Id] = [pu].[UserId] +END diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs index 4781127a3d..3c9fd9a9e9 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -21,52 +21,23 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidat public class AutomaticUserConfirmationPolicyEventHandlerTests { [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + public void RequiredPolicies_IncludesSingleOrg( SutProvider sutProvider) { - // Arrange - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns((Policy?)null); - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + var requiredPolicies = sutProvider.Sut.RequiredPolicies; // Assert - Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy, - SutProvider sutProvider) - { - // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + Assert.Contains(PolicyType.SingleOrg, requiredPolicies); } [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid nonCompliantUserId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var orgUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -85,10 +56,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Status = OrganizationUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([orgUser]); @@ -107,13 +74,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid userId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var orgUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -121,7 +85,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, UserId = userId, - Email = "test@email.com" }; var otherOrgUser = new OrganizationUser @@ -133,10 +96,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Email = orgUser.Email }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([orgUser]); @@ -146,7 +105,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests .Returns([otherOrgUser]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act @@ -159,30 +118,37 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid userId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = userId + }; var providerUser = new ProviderUser { Id = Guid.NewGuid(), ProviderId = Guid.NewGuid(), - UserId = Guid.NewGuid(), + UserId = userId, Status = ProviderUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([providerUser]); // Act @@ -196,26 +162,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var orgUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), OrganizationId = policyUpdate.OrganizationId, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Confirmed, - UserId = Guid.NewGuid(), - Email = "user@example.com" + UserId = Guid.NewGuid() }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([orgUser]); @@ -225,7 +183,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests .Returns([]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act @@ -249,9 +207,10 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests // Assert Assert.True(string.IsNullOrEmpty(result)); - await sutProvider.GetDependency() + + await sutProvider.GetDependency() .DidNotReceive() - .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + .GetManyDetailsByOrganizationAsync(Arg.Any()); } [Theory, BitAutoData] @@ -268,21 +227,18 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests // Assert Assert.True(string.IsNullOrEmpty(result)); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + .GetManyDetailsByOrganizationAsync(Arg.Any()); } [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid nonCompliantOwnerId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var ownerUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -290,7 +246,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.Owner, Status = OrganizationUserStatusType.Confirmed, UserId = nonCompliantOwnerId, - Email = "owner@example.com" }; var otherOrgUser = new OrganizationUser @@ -301,10 +256,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Status = OrganizationUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([ownerUser]); @@ -323,12 +274,9 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var invitedUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -339,16 +287,12 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Email = "invited@example.com" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([invitedUser]); sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act @@ -359,14 +303,11 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests } [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck( + public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var revokedUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -374,38 +315,44 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Revoked, UserId = Guid.NewGuid(), - Email = "revoked@example.com" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); + var additionalOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Revoked, + UserId = revokedUser.UserId, + }; - sutProvider.GetDependency() + var orgUserRepository = sutProvider.GetDependency(); + + orgUserRepository .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([revokedUser]); + orgUserRepository.GetManyByManyUsersAsync(Arg.Any>()) + .Returns([additionalOrgUser]); + sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .GetManyByManyUsersAsync(Arg.Any>()) .Returns([]); // Act var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); // Assert - Assert.True(string.IsNullOrEmpty(result)); + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); } [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, Guid nonCompliantUserId, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var acceptedUser = new OrganizationUserUserDetails { Id = Guid.NewGuid(), @@ -413,7 +360,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Accepted, UserId = nonCompliantUserId, - Email = "accepted@example.com" }; var otherOrgUser = new OrganizationUser @@ -424,10 +370,6 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Status = OrganizationUserStatusType.Confirmed }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([acceptedUser]); @@ -443,186 +385,22 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); } - [Theory, BitAutoData] - public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, - SutProvider sutProvider) - { - // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); - - sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); - - // Act - var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); - - // Assert - Assert.True(string.IsNullOrEmpty(result)); - } - [Theory, BitAutoData] public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, SutProvider sutProvider) { // Arrange - singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; - var savePolicyModel = new SavePolicyModel(policyUpdate); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) - .Returns(singleOrgPolicy); - sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) .Returns([]); - sutProvider.GetDependency() - .GetManyByOrganizationAsync(policyUpdate.OrganizationId) - .Returns([]); - // Act var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); // Assert Assert.True(string.IsNullOrEmpty(result)); } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - organization.UseAutomaticUserConfirmation = false; - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.UseAutomaticUserConfirmation == true && - o.RevisionDate > DateTime.MinValue)); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - organization.UseAutomaticUserConfirmation = true; - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.UseAutomaticUserConfirmation == false && - o.RevisionDate > DateTime.MinValue)); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns((Organization?)null); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .DidNotReceive() - .UpsertAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - currentPolicy.OrganizationId = policyUpdate.OrganizationId; - - var savePolicyModel = new SavePolicyModel(policyUpdate); - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.UseAutomaticUserConfirmation == policyUpdate.Enabled)); - } - - [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate( - [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.Id = policyUpdate.OrganizationId; - var originalRevisionDate = DateTime.UtcNow.AddDays(-1); - organization.RevisionDate = originalRevisionDate; - - sutProvider.GetDependency() - .GetByIdAsync(policyUpdate.OrganizationId) - .Returns(organization); - - // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .UpsertAsync(Arg.Is(o => - o.Id == organization.Id && - o.RevisionDate > originalRevisionDate)); - } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs index 0d1d28f33d..b502c6c997 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs @@ -89,6 +89,286 @@ public class ProviderUserRepositoryTests Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig); } + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithMultipleUsers_ReturnsAllProviderUsers( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var user3 = await userRepository.CreateTestUserAsync(); + + var provider1 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 1", + Enabled = true, + Type = ProviderType.Msp + }); + + var provider2 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 2", + Enabled = true, + Type = ProviderType.Reseller + }); + + var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider1.Id, + UserId = user1.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider1.Id, + UserId = user2.Id, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ServiceUser + }); + + var providerUser3 = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider2.Id, + UserId = user3.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { user1.Id, user2.Id, user3.Id }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Equal(3, results.Count); + Assert.Contains(results, pu => pu.Id == providerUser1.Id && pu.UserId == user1.Id); + Assert.Contains(results, pu => pu.Id == providerUser2.Id && pu.UserId == user2.Id); + Assert.Contains(results, pu => pu.Id == providerUser3.Id && pu.UserId == user3.Id); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithSingleUser_ReturnsSingleProviderUser( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + var providerUser = await providerUserRepository.CreateAsync(new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList(); + + Assert.Single(results); + Assert.Equal(user.Id, results[0].UserId); + Assert.Equal(provider.Id, results[0].ProviderId); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithUserHavingMultipleProviders_ReturnsAllProviderUsers( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user = await userRepository.CreateTestUserAsync(); + + var provider1 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 1", + Enabled = true, + Type = ProviderType.Msp + }); + + var provider2 = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider 2", + Enabled = true, + Type = ProviderType.Reseller + }); + + var providerUser1 = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider1.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var providerUser2 = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider2.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ServiceUser + }); + + var results = (await providerUserRepository.GetManyByManyUsersAsync([user.Id])).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, pu => pu.Id == providerUser1.Id); + Assert.Contains(results, pu => pu.Id == providerUser2.Id); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithEmptyUserIds_ReturnsEmpty( + IProviderUserRepository providerUserRepository) + { + var results = await providerUserRepository.GetManyByManyUsersAsync(Array.Empty()); + + Assert.Empty(results); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithNonExistentUserIds_ReturnsEmpty( + IProviderUserRepository providerUserRepository) + { + var nonExistentUserIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; + + var results = await providerUserRepository.GetManyByManyUsersAsync(nonExistentUserIds); + + Assert.Empty(results); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_WithMixedExistentAndNonExistentUserIds_ReturnsOnlyExistent( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var existingUser = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + var providerUser = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = existingUser.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { existingUser.Id, Guid.NewGuid(), Guid.NewGuid() }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Single(results); + Assert.Equal(existingUser.Id, results[0].UserId); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_ReturnsAllStatuses( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var user3 = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user1.Id, + Status = ProviderUserStatusType.Invited, + Type = ProviderUserType.ServiceUser + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user2.Id, + Status = ProviderUserStatusType.Accepted, + Type = ProviderUserType.ServiceUser + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user3.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { user1.Id, user2.Id, user3.Id }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Equal(3, results.Count); + Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Status == ProviderUserStatusType.Invited); + Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Status == ProviderUserStatusType.Accepted); + Assert.Contains(results, pu => pu.UserId == user3.Id && pu.Status == ProviderUserStatusType.Confirmed); + } + + [Theory, DatabaseData] + public async Task GetManyByManyUsersAsync_ReturnsAllProviderUserTypes( + IUserRepository userRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user1.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ServiceUser + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user2.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var userIds = new[] { user1.Id, user2.Id }; + + var results = (await providerUserRepository.GetManyByManyUsersAsync(userIds)).ToList(); + + Assert.Equal(2, results.Count); + Assert.Contains(results, pu => pu.UserId == user1.Id && pu.Type == ProviderUserType.ServiceUser); + Assert.Contains(results, pu => pu.UserId == user2.Id && pu.Type == ProviderUserType.ProviderAdmin); + } + private static void AssertProviderOrganizationDetails( ProviderUserOrganizationDetails actual, Organization expectedOrganization, @@ -139,4 +419,6 @@ public class ProviderUserRepositoryTests Assert.Equal(expectedProviderUser.Status, actual.Status); Assert.Equal(expectedProviderUser.Type, actual.Type); } + + } diff --git a/util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql b/util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql new file mode 100644 index 0000000000..b112e02263 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-03_00_ProviderUserGetManyByUserIds.sql @@ -0,0 +1,13 @@ +CREATE OR ALTER PROCEDURE [dbo].[ProviderUser_ReadManyByManyUserIds] + @UserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + [pu].* + FROM + [dbo].[ProviderUserView] AS [pu] + INNER JOIN + @UserIds [u] ON [u].[Id] = [pu].[UserId] +END From 5469d8be0e4d71c614b055296a74bf0ded31c1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:28:04 +0000 Subject: [PATCH 40/89] [PM-28260] Optimize bulk reinvite endpoint (#6670) * Implement optimized bulk invite resend command - Added IBulkResendOrganizationInvitesCommand interface to define the bulk resend operation. - Created BulkResendOrganizationInvitesCommand class to handle the logic for resending invites to multiple organization users. - Integrated logging and validation to ensure only valid users receive invites. - Included error handling for non-existent organizations and invalid user statuses. * Add unit tests for BulkResendOrganizationInvitesCommand - Implemented comprehensive test cases for the BulkResendOrganizationInvitesCommand class. - Validated user statuses and ensured correct handling of valid and invalid users during bulk invite resends. - Included tests for scenarios such as organization not found and empty user lists. - Utilized Xunit and NSubstitute for effective testing and mocking of dependencies. * Add IBulkResendOrganizationInvitesCommand to service collection - Registered IBulkResendOrganizationInvitesCommand in the service collection for dependency injection. * Update OrganizationUsersController to utilize IBulkResendOrganizationInvitesCommand - Added IBulkResendOrganizationInvitesCommand to the OrganizationUsersController for handling bulk invite resends based on feature flag. - Updated BulkReinvite method to conditionally use the new command or the legacy service based on the feature flag status. - Enhanced unit tests to verify correct command usage depending on feature flag state, ensuring robust testing for both scenarios. --- .../OrganizationUsersController.cs | 15 ++- .../BulkResendOrganizationInvitesCommand.cs | 69 +++++++++++ .../IBulkResendOrganizationInvitesCommand.cs | 20 ++++ ...OrganizationServiceCollectionExtensions.cs | 1 + .../OrganizationUsersControllerTests.cs | 65 ++++++++++ ...lkResendOrganizationInvitesCommandTests.cs | 113 ++++++++++++++++++ 6 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 55b9caa550..d78c462005 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -71,6 +71,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand; private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; @@ -105,6 +106,7 @@ public class OrganizationUsersController : BaseAdminConsoleController IInitPendingOrganizationCommand initPendingOrganizationCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand) { @@ -131,6 +133,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _featureService = featureService; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand; _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; @@ -273,7 +276,17 @@ public class OrganizationUsersController : BaseAdminConsoleController public async Task> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + + IEnumerable> result; + if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)) + { + result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids); + } + else + { + result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + } + return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..c7c80bd937 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,69 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class BulkResendOrganizationInvitesCommand : IBulkResendOrganizationInvitesCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public BulkResendOrganizationInvitesCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId) + { + var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); + _logger.LogUserInviteStateDiagnostics(orgUsers); + + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException(); + } + + var validUsers = new List(); + var result = new List>(); + + foreach (var orgUser in orgUsers) + { + if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId) + { + result.Add(Tuple.Create(orgUser, "User invalid.")); + } + else + { + validUsers.Add(orgUser); + } + } + + if (validUsers.Any()) + { + await _sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest(validUsers, org)); + + result.AddRange(validUsers.Select(u => Tuple.Create(u, ""))); + } + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..342a06fcf9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IBulkResendOrganizationInvitesCommand +{ + /// + /// Resend invites to multiple organization users in bulk. + /// + /// The ID of the organization. + /// The ID of the user who is resending the invites. + /// The IDs of the organization users to resend invites to. + /// A tuple containing the OrganizationUser and an error message (empty string if successful) + Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId); +} + + diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 91030c5151..9cb9159ebb 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -197,6 +197,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index cb03844aa2..43f0123a3f 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -730,4 +731,68 @@ public class OrganizationUsersControllerTests var problemResult = Assert.IsType>(result); Assert.Equal(StatusCodes.Status500InternalServerError, problemResult.StatusCode); } + + [Theory] + [BitAutoData] + public async Task BulkReinvite_WhenFeatureFlagEnabled_UsesBulkResendOrganizationInvitesCommand( + Guid organizationId, + OrganizationUserBulkRequestModel bulkRequestModel, + List organizationUsers, + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().ManageUsers(organizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud) + .Returns(true); + + var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList(); + sutProvider.GetDependency() + .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids) + .Returns(expectedResults); + + // Act + var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel); + + // Assert + Assert.Equal(organizationUsers.Count, response.Data.Count()); + + await sutProvider.GetDependency() + .Received(1) + .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); + } + + [Theory] + [BitAutoData] + public async Task BulkReinvite_WhenFeatureFlagDisabled_UsesLegacyOrganizationService( + Guid organizationId, + OrganizationUserBulkRequestModel bulkRequestModel, + List organizationUsers, + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().ManageUsers(organizationId).Returns(true); + sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud) + .Returns(false); + + var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList(); + sutProvider.GetDependency() + .ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids) + .Returns(expectedResults); + + // Act + var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel); + + // Assert + Assert.Equal(organizationUsers.Count, response.Data.Count()); + + await sutProvider.GetDependency() + .Received(1) + .ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs new file mode 100644 index 0000000000..caae3a3b12 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommandTests.cs @@ -0,0 +1,113 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +[SutProviderCustomize] +public class BulkResendOrganizationInvitesCommandTests +{ + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_ValidatesUsersAndSendsBatchInvite( + Organization organization, + OrganizationUser validUser1, + OrganizationUser validUser2, + OrganizationUser acceptedUser, + OrganizationUser wrongOrgUser, + SutProvider sutProvider) + { + validUser1.OrganizationId = organization.Id; + validUser1.Status = OrganizationUserStatusType.Invited; + validUser2.OrganizationId = organization.Id; + validUser2.Status = OrganizationUserStatusType.Invited; + acceptedUser.OrganizationId = organization.Id; + acceptedUser.Status = OrganizationUserStatusType.Accepted; + wrongOrgUser.OrganizationId = Guid.NewGuid(); + wrongOrgUser.Status = OrganizationUserStatusType.Invited; + + var users = new List { validUser1, validUser2, acceptedUser, wrongOrgUser }; + var userIds = users.Select(u => u.Id).ToList(); + + sutProvider.GetDependency().GetManyAsync(userIds).Returns(users); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList(); + + Assert.Equal(4, result.Count); + Assert.Equal(2, result.Count(r => string.IsNullOrEmpty(r.Item2))); + Assert.Equal(2, result.Count(r => r.Item2 == "User invalid.")); + + await sutProvider.GetDependency() + .Received(1) + .SendInvitesAsync(Arg.Is(req => + req.Organization == organization && + req.Users.Length == 2 && + req.InitOrganization == false)); + } + + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_AllInvalidUsers_DoesNotSendInvites( + Organization organization, + List organizationUsers, + SutProvider sutProvider) + { + foreach (var user in organizationUsers) + { + user.OrganizationId = organization.Id; + user.Status = OrganizationUserStatusType.Confirmed; + } + + var userIds = organizationUsers.Select(u => u.Id).ToList(); + sutProvider.GetDependency().GetManyAsync(userIds).Returns(organizationUsers); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var result = (await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, userIds)).ToList(); + + Assert.Equal(organizationUsers.Count, result.Count); + Assert.All(result, r => Assert.Equal("User invalid.", r.Item2)); + await sutProvider.GetDependency().DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_OrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, + List userIds, + List organizationUsers, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetManyAsync(userIds).Returns(organizationUsers); + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns((Organization?)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.BulkResendInvitesAsync(organizationId, null, userIds)); + } + + [Theory] + [BitAutoData] + public async Task BulkResendInvitesAsync_EmptyUserList_ReturnsEmpty( + Organization organization, + SutProvider sutProvider) + { + var emptyUserIds = new List(); + sutProvider.GetDependency().GetManyAsync(emptyUserIds).Returns(new List()); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.BulkResendInvitesAsync(organization.Id, null, emptyUserIds); + + Assert.Empty(result); + await sutProvider.GetDependency().DidNotReceive() + .SendInvitesAsync(Arg.Any()); + } +} From d5f39eac9122ee57e9e80eb9a1487957ef681411 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:35:37 -0500 Subject: [PATCH 41/89] [PM-28769] [PM-28768] [PM-28772] Welcome email bug fixes (#6644) Fix: fix bugs reported by QA for Welcome emails * test: add test for new plan type in welcome email * fix: change to headStyle so styling is only included once * fix: update MJML templates to have correct copy text * chore: move build artifacts for updated email templates * fix: add setting for SMTP to SSO project * fix: update component css styling * chore: rebuild hbs templates * fix: using billing extension method to fetch Correct PlanType. --- .../src/Sso/appsettings.Development.json | 8 ++- bitwarden_license/src/Sso/appsettings.json | 6 +- .../Implementations/RegisterUserCommand.cs | 5 +- .../Onboarding/welcome-family-user.html.hbs | 65 ++++++++++--------- .../welcome-individual-user.html.hbs | 63 +++++++++--------- .../Auth/Onboarding/welcome-org-user.html.hbs | 65 ++++++++++--------- .../Mjml/components/mj-bw-icon-row.js | 18 ++--- .../Auth/Onboarding/welcome-family-user.mjml | 2 +- .../Onboarding/welcome-individual-user.mjml | 2 +- .../Auth/Onboarding/welcome-org-user.mjml | 2 +- .../Registration/RegisterUserCommandTests.cs | 1 + 11 files changed, 131 insertions(+), 106 deletions(-) diff --git a/bitwarden_license/src/Sso/appsettings.Development.json b/bitwarden_license/src/Sso/appsettings.Development.json index 6d9ec77815..8e24d82528 100644 --- a/bitwarden_license/src/Sso/appsettings.Development.json +++ b/bitwarden_license/src/Sso/appsettings.Development.json @@ -25,6 +25,12 @@ "connectionString": "UseDevelopmentStorage=true" }, "developmentDirectory": "../../../dev", - "pricingUri": "https://billingpricing.qa.bitwarden.pw" + "pricingUri": "https://billingpricing.qa.bitwarden.pw", + "mail": { + "smtp": { + "host": "localhost", + "port": 10250 + } + } } } diff --git a/bitwarden_license/src/Sso/appsettings.json b/bitwarden_license/src/Sso/appsettings.json index 73c85044cc..9a5df42f7f 100644 --- a/bitwarden_license/src/Sso/appsettings.json +++ b/bitwarden_license/src/Sso/appsettings.json @@ -13,7 +13,11 @@ "mail": { "sendGridApiKey": "SECRET", "amazonConfigSetName": "Email", - "replyToEmail": "no-reply@bitwarden.com" + "replyToEmail": "no-reply@bitwarden.com", + "smtp": { + "host": "localhost", + "port": 10250 + } }, "identityServer": { "certificateThumbprint": "SECRET" diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index baeb24368e..be85a858a3 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -455,9 +456,7 @@ public class RegisterUserCommand : IRegisterUserCommand else if (!string.IsNullOrEmpty(organization.DisplayName())) { // If the organization is Free or Families plan, send families welcome email - if (organization.PlanType is PlanType.FamiliesAnnually - or PlanType.FamiliesAnnually2019 - or PlanType.Free) + if (organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families) { await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName()); } diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs index 3cbc9446c8..9c4b2406d4 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -53,11 +53,37 @@ - - + @@ -156,7 +161,7 @@

    - Let's get set up to autofill. + Let’s get you set up to autofill.

    @@ -176,7 +181,7 @@ - + @@ -256,7 +261,7 @@ @@ -643,7 +648,7 @@ -
    -
    A {{OrganizationName}} administrator will approve you +
    An administrator from {{OrganizationName}} will approve you before you can share passwords. While you wait for approval, get started with Bitwarden Password Manager:
    @@ -622,10 +627,10 @@

    - Learn more about Bitwarden -

    - Find user guides, product documentation, and videos on the - Bitwarden Help Center.
    + Learn more about Bitwarden +

    + Find user guides, product documentation, and videos on the + Bitwarden Help Center.
    +