From 72c896793799d36de396ac82d1a170f5786759c2 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:52:32 -0500 Subject: [PATCH 01/47] Add CQRS and caching support for OrganizationIntegrationConfigurations (#6690) --- ...ationIntegrationConfigurationController.cs | 65 +- ...ionIntegrationConfigurationRequestModel.cs | 82 +- ...IntegrationsServiceCollectionExtensions.cs | 19 +- ...nizationIntegrationConfigurationCommand.cs | 64 ++ ...nizationIntegrationConfigurationCommand.cs | 54 ++ ...anizationIntegrationConfigurationsQuery.cs | 29 + ...nizationIntegrationConfigurationCommand.cs | 22 + ...nizationIntegrationConfigurationCommand.cs | 19 + ...anizationIntegrationConfigurationsQuery.cs | 19 + ...nizationIntegrationConfigurationCommand.cs | 25 + ...nizationIntegrationConfigurationCommand.cs | 82 ++ ...zationIntegrationConfigurationValidator.cs | 17 + ...zationIntegrationConfigurationValidator.cs | 76 ++ .../EventIntegrationsCacheConstants.cs | 6 +- ...ntegrationsConfigurationControllerTests.cs | 774 ++---------------- ...tegrationConfigurationRequestModelTests.cs | 248 ------ ...grationServiceCollectionExtensionsTests.cs | 41 +- ...ionIntegrationConfigurationCommandTests.cs | 179 ++++ ...ionIntegrationConfigurationCommandTests.cs | 211 +++++ ...tionIntegrationConfigurationsQueryTests.cs | 101 +++ ...ionIntegrationConfigurationCommandTests.cs | 390 +++++++++ ...nIntegrationConfigurationValidatorTests.cs | 244 ++++++ .../EventIntegrationsCacheConstantsTests.cs | 14 - 23 files changed, 1676 insertions(+), 1105 deletions(-) create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs create mode 100644 src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs create mode 100644 src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs create mode 100644 src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs delete mode 100644 test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs create mode 100644 test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs create mode 100644 test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs create mode 100644 test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs create mode 100644 test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs create mode 100644 test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 0b7fe8dffe..f172a23529 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -1,8 +1,8 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,8 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class OrganizationIntegrationConfigurationController( ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository, - IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller + ICreateOrganizationIntegrationConfigurationCommand createCommand, + IUpdateOrganizationIntegrationConfigurationCommand updateCommand, + IDeleteOrganizationIntegrationConfigurationCommand deleteCommand, + IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller { [HttpGet("")] public async Task> GetAsync( @@ -24,13 +26,8 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); + var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId); return configurations .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) .ToList(); @@ -46,19 +43,11 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId); - var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration); - return new OrganizationIntegrationConfigurationResponseModel(configuration); + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var created = await createCommand.CreateAsync(organizationId, integrationId, configuration); + + return new OrganizationIntegrationConfigurationResponseModel(created); } [HttpPut("{configurationId:guid}")] @@ -72,26 +61,11 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration); - var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration); - await integrationConfigurationRepository.ReplaceAsync(newConfiguration); - - return new OrganizationIntegrationConfigurationResponseModel(newConfiguration); + return new OrganizationIntegrationConfigurationResponseModel(updated); } [HttpDelete("{configurationId:guid}")] @@ -101,19 +75,8 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } - - await integrationConfigurationRepository.DeleteAsync(configuration); + await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId); } [HttpPost("{configurationId:guid}/delete")] diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 8581c4ae1f..9341392d68 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,6 +1,4 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; @@ -16,38 +14,6 @@ public class OrganizationIntegrationConfigurationRequestModel public string? Template { get; set; } - public bool IsValidForType(IntegrationType integrationType) - { - switch (integrationType) - { - case IntegrationType.CloudBillingSync or IntegrationType.Scim: - return false; - case IntegrationType.Slack: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Webhook: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Hec: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Datadog: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Teams: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - default: - return false; - - } - } - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) { return new OrganizationIntegrationConfiguration() @@ -59,50 +25,4 @@ public class OrganizationIntegrationConfigurationRequestModel Template = Template }; } - - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration) - { - currentConfiguration.Configuration = Configuration; - currentConfiguration.EventType = EventType; - currentConfiguration.Filters = Filters; - currentConfiguration.Template = Template; - - return currentConfiguration; - } - - private bool IsConfigurationValid() - { - if (string.IsNullOrWhiteSpace(Configuration)) - { - return false; - } - - try - { - var config = JsonSerializer.Deserialize(Configuration); - return config is not null; - } - catch - { - return false; - } - } - - private bool IsFiltersValid() - { - if (Filters is null) - { - return true; - } - - try - { - var filters = JsonSerializer.Deserialize(Filters); - return filters is not null; - } - catch - { - return false; - } - } } diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index 9ebe09ebcc..6b848be11f 100644 --- a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ -using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -20,8 +23,12 @@ public static class EventIntegrationsServiceCollectionExtensions // This is idempotent for the same named cache, so it's safe to call. services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + // Add Validator + services.TryAddSingleton(); + // Add all commands/queries services.AddOrganizationIntegrationCommandsQueries(); + services.AddOrganizationIntegrationConfigurationCommandsQueries(); return services; } @@ -35,4 +42,14 @@ public static class EventIntegrationsServiceCollectionExtensions return services; } + + internal static IServiceCollection AddOrganizationIntegrationConfigurationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } } diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..cb3ce8b9ea --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,64 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for creating organization integration configurations with validation and cache invalidation support. +/// +public class CreateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : ICreateOrganizationIntegrationConfigurationCommand +{ + public async Task CreateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegrationConfiguration configuration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, configuration)) + { + throw new BadRequestException( + $"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + var created = await configurationRepository.CreateAsync(configuration); + + // Invalidate the cached configuration details + // Even though this is a new record, the cache could hold a stale empty list for this + if (created.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: created.EventType.Value + )); + } + + return created; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..78768fd0d4 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,54 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for deleting organization integration configurations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationConfigurationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + + await configurationRepository.DeleteAsync(configuration); + + if (configuration.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + } + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..a2078c3c98 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,29 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Query implementation for retrieving organization integration configurations. +/// +public class GetOrganizationIntegrationConfigurationsQuery( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IGetOrganizationIntegrationConfigurationsQuery +{ + public async Task> GetManyByIntegrationAsync( + Guid organizationId, + Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configurations = await configurationRepository.GetManyByIntegrationAsync(integrationId); + return configurations.ToList(); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..140cc79d1a --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for creating organization integration configurations. +/// +public interface ICreateOrganizationIntegrationConfigurationCommand +{ + /// + /// Creates a new configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The configuration to create. + /// The created configuration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task CreateAsync(Guid organizationId, Guid integrationId, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3970676d40 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for deleting organization integration configurations. +/// +public interface IDeleteOrganizationIntegrationConfigurationCommand +{ + /// + /// Deletes a configuration from an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to delete. + /// + /// Thrown when the integration or configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..2bf806c458 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Query interface for retrieving organization integration configurations. +/// +public interface IGetOrganizationIntegrationConfigurationsQuery +{ + /// + /// Retrieves all configurations for a specific organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// A list of configurations associated with the integration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task> GetManyByIntegrationAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3e60a0af07 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for updating organization integration configurations. +/// +public interface IUpdateOrganizationIntegrationConfigurationCommand +{ + /// + /// Updates an existing configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to update. + /// The updated configuration data. + /// The updated configuration. + /// + /// Thrown when the integration or the configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task UpdateAsync(Guid organizationId, Guid integrationId, Guid configurationId, OrganizationIntegrationConfiguration updatedConfiguration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..f619e2ddf2 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,82 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for updating organization integration configurations with validation and cache invalidation support. +/// +public class UpdateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : IUpdateOrganizationIntegrationConfigurationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration updatedConfiguration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, updatedConfiguration)) + { + throw new BadRequestException($"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + updatedConfiguration.Id = configuration.Id; + updatedConfiguration.CreationDate = configuration.CreationDate; + await configurationRepository.ReplaceAsync(updatedConfiguration); + + // If either old or new EventType is null (wildcard), invalidate all cached results + // for the specific integration + if (configuration.EventType == null || updatedConfiguration.EventType == null) + { + // Wildcard involved - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedConfiguration; + } + + // Both are specific event types - invalidate specific cache entries + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + + // If event type changed, also clear the new event type's cache + if (configuration.EventType != updatedConfiguration.EventType) + { + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: updatedConfiguration.EventType.Value + )); + } + + return updatedConfiguration; + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs b/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..48346cbae7 --- /dev/null +++ b/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public interface IOrganizationIntegrationConfigurationValidator +{ + /// + /// Validates that the configuration is valid for the given integration type. The configuration must + /// include a Configuration that is valid for the type, valid Filters, and a non-empty Template + /// to pass validation. + /// + /// The type of integration + /// The OrganizationIntegrationConfiguration to validate + /// True if valid, false otherwise + bool ValidateConfiguration(IntegrationType integrationType, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs b/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..2769565675 --- /dev/null +++ b/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public class OrganizationIntegrationConfigurationValidator : IOrganizationIntegrationConfigurationValidator +{ + public bool ValidateConfiguration(IntegrationType integrationType, + OrganizationIntegrationConfiguration configuration) + { + // Validate template is present + if (string.IsNullOrWhiteSpace(configuration.Template)) + { + return false; + } + // If Filters are present, they must be valid + if (!IsFiltersValid(configuration.Filters)) + { + return false; + } + + switch (integrationType) + { + case IntegrationType.CloudBillingSync or IntegrationType.Scim: + return false; + case IntegrationType.Slack: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Webhook: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Hec: + case IntegrationType.Datadog: + case IntegrationType.Teams: + return configuration.Configuration is null; + default: + return false; + } + } + + private static bool IsConfigurationValid(string? configuration) + { + if (string.IsNullOrWhiteSpace(configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(configuration); + return config is not null; + } + catch + { + return false; + } + } + + private static bool IsFiltersValid(string? filters) + { + if (filters is null) + { + return true; + } + + try + { + var filterGroup = JsonSerializer.Deserialize(filters); + return filterGroup is not null; + } + catch + { + return false; + } + } +} diff --git a/src/Core/Utilities/EventIntegrationsCacheConstants.cs b/src/Core/Utilities/EventIntegrationsCacheConstants.cs index 6bd90c797d..19cc3f949c 100644 --- a/src/Core/Utilities/EventIntegrationsCacheConstants.cs +++ b/src/Core/Utilities/EventIntegrationsCacheConstants.cs @@ -55,16 +55,16 @@ public static class EventIntegrationsCacheConstants /// Builds a deterministic cache key for an organization's integration configuration details /// . /// - /// The unique identifier of the organization to which the user belongs. + /// The unique identifier of the organization. /// The of the integration. - /// The of the event configured. Can be null to apply to all events. + /// The specific of the event configured. /// /// A cache key for the configuration details. /// public static string BuildCacheKeyForOrganizationIntegrationConfigurationDetails( Guid organizationId, IntegrationType integrationType, - EventType? eventType + EventType eventType ) => $"OrganizationIntegrationConfigurationDetails:{organizationId:N}:{integrationType}:{eventType}"; /// diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs index 9ab626d3f0..6e1dadb92f 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -1,18 +1,14 @@ -using System.Text.Json; -using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.Context; -using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Api.Test.AdminConsole.Controllers; @@ -25,823 +21,191 @@ public class OrganizationIntegrationsConfigurationControllerTests public async Task DeleteAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration) + Guid integrationId, + Guid configurationId) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegrationConfiguration.Id); - await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationIntegrationConfiguration); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, integrationId, configurationId); } [Theory, BitAutoData] + [Obsolete("Obsolete")] public async Task PostDeleteAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegrationConfiguration.Id); - await sutProvider.GetDependency().Received(1) - .DeleteAsync(organizationIntegrationConfiguration); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - organizationIntegration.OrganizationId = organizationId; - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) + Guid integrationId, + Guid configurationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); - } + await sutProvider.Sut.PostDeleteAsync(organizationId, integrationId, configurationId); - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty)); - } - - [Theory, BitAutoData] - public async Task DeleteAsync_IntegrationConfigDoesNotBelongToIntegration_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = Guid.Empty; - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty)); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationId, integrationId, configurationId); } [Theory, BitAutoData] public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, - Guid organizationId) + Guid organizationId, + Guid integrationId, + Guid configurationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); } [Theory, BitAutoData] public async Task GetAsync_ConfigurationsExist_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - List organizationIntegrationConfigurations) + Guid integrationId, + List configurations) { - organizationIntegration.OrganizationId = organizationId; sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetManyByIntegrationAsync(Arg.Any()) - .Returns(organizationIntegrationConfigurations); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(organizationId, integrationId) + .Returns(configurations); + + var result = await sutProvider.Sut.GetAsync(organizationId, integrationId); - var result = await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id); Assert.NotNull(result); - Assert.Equal(organizationIntegrationConfigurations.Count, result.Count); + Assert.Equal(configurations.Count, result.Count); Assert.All(result, r => Assert.IsType(r)); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetManyByIntegrationAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(organizationId, integrationId); } [Theory, BitAutoData] public async Task GetAsync_NoConfigurationsExist_ReturnsEmptyList( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration) + Guid integrationId) { - organizationIntegration.OrganizationId = organizationId; sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetManyByIntegrationAsync(Arg.Any()) + sutProvider.GetDependency() + .GetManyByIntegrationAsync(organizationId, integrationId) .Returns([]); - var result = await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id); + var result = await sutProvider.Sut.GetAsync(organizationId, integrationId); + Assert.NotNull(result); Assert.Empty(result); - - await sutProvider.GetDependency().Received(1) - .GetByIdAsync(organizationIntegration.Id); - await sutProvider.GetDependency().Received(1) - .GetManyByIntegrationAsync(organizationIntegration.Id); - } - - [Theory, BitAutoData] - public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.NewGuid())); - } - - [Theory, BitAutoData] - public async Task GetAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id)); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(organizationId, integrationId); } [Theory, BitAutoData] public async Task GetAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, - Guid organizationId) + Guid organizationId, + Guid integrationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.NewGuid())); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetAsync(organizationId, integrationId)); } [Theory, BitAutoData] - public async Task PostAsync_AllParamsProvided_Slack_Succeeds( + public async Task PostAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + Guid integrationId, + OrganizationIntegrationConfiguration configuration, OrganizationIntegrationConfigurationRequestModel model) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Slack; - var slackConfig = new SlackIntegrationConfiguration(ChannelId: "C123456"); - model.Configuration = JsonSerializer.Serialize(slackConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); - sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + sutProvider.GetDependency() + .CreateAsync(organizationId, integrationId, Arg.Any()) + .Returns(configuration); - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, integrationId, model); + + await sutProvider.GetDependency().Received(1) + .CreateAsync(organizationId, integrationId, Arg.Any()); Assert.IsType(createResponse); - Assert.Equal(expected.Id, createResponse.Id); - Assert.Equal(expected.Configuration, createResponse.Configuration); - Assert.Equal(expected.EventType, createResponse.EventType); - Assert.Equal(expected.Filters, createResponse.Filters); - Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] - public async Task PostAsync_AllParamsProvided_Webhook_Succeeds( + public async Task PostAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); - Assert.IsType(createResponse); - Assert.Equal(expected.Id, createResponse.Id); - Assert.Equal(expected.Configuration, createResponse.Configuration); - Assert.Equal(expected.EventType, createResponse.EventType); - Assert.Equal(expected.Filters, createResponse.Filters); - Assert.Equal(expected.Template, createResponse.Template); - } - - [Theory, BitAutoData] - public async Task PostAsync_OnlyUrlProvided_Webhook_Succeeds( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); - - await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); - Assert.IsType(createResponse); - Assert.Equal(expected.Id, createResponse.Id); - Assert.Equal(expected.Configuration, createResponse.Configuration); - Assert.Equal(expected.EventType, createResponse.EventType); - Assert.Equal(expected.Filters, createResponse.Filters); - Assert.Equal(expected.Template, createResponse.Template); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationTypeCloudBillingSync_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.CloudBillingSync; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationTypeScim_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Scim; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task PostAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task PostAsync_InvalidConfiguration_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - model.Configuration = null; - model.Template = "Template String"; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_InvalidTemplate_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = null; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync( - organizationId, - organizationIntegration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task PostAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + Guid integrationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, Guid.Empty, new OrganizationIntegrationConfigurationRequestModel())); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(organizationId, integrationId, new OrganizationIntegrationConfigurationRequestModel())); } [Theory, BitAutoData] - public async Task UpdateAsync_AllParamsProvided_Slack_Succeeds( + public async Task UpdateAsync_AllParamsProvided_Succeeds( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration configuration, OrganizationIntegrationConfigurationRequestModel model) { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Slack; - var slackConfig = new SlackIntegrationConfiguration(ChannelId: "C123456"); - model.Configuration = JsonSerializer.Serialize(slackConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); - sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var updateResponse = await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model); + sutProvider.GetDependency() + .UpdateAsync(organizationId, integrationId, configurationId, Arg.Any()) + .Returns(configuration); - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Any()); + var updateResponse = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, model); + + await sutProvider.GetDependency().Received(1) + .UpdateAsync(organizationId, integrationId, configurationId, Arg.Any()); Assert.IsType(updateResponse); - Assert.Equal(expected.Id, updateResponse.Id); - Assert.Equal(expected.Configuration, updateResponse.Configuration); - Assert.Equal(expected.EventType, updateResponse.EventType); - Assert.Equal(expected.Filters, updateResponse.Filters); - Assert.Equal(expected.Template, updateResponse.Template); } - [Theory, BitAutoData] - public async Task UpdateAsync_AllParamsProvided_Webhook_Succeeds( + public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( SutProvider sutProvider, Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var updateResponse = await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model); - - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Any()); - Assert.IsType(updateResponse); - Assert.Equal(expected.Id, updateResponse.Id); - Assert.Equal(expected.Configuration, updateResponse.Configuration); - Assert.Equal(expected.EventType, updateResponse.EventType); - Assert.Equal(expected.Filters, updateResponse.Filters); - Assert.Equal(expected.Template, updateResponse.Template); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_OnlyUrlProvided_Webhook_Succeeds( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration)); - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - var updateResponse = await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model); - - await sutProvider.GetDependency().Received(1) - .ReplaceAsync(Arg.Any()); - Assert.IsType(updateResponse); - Assert.Equal(expected.Id, updateResponse.Id); - Assert.Equal(expected.Configuration, updateResponse.Configuration); - Assert.Equal(expected.EventType, updateResponse.EventType); - Assert.Equal(expected.Filters, updateResponse.Filters); - Assert.Equal(expected.Template, updateResponse.Template); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); - model.Configuration = JsonSerializer.Serialize(webhookConfig); - model.Template = "Template String"; - model.Filters = null; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - Guid.Empty, - model)); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .ReturnsNull(); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - Guid.Empty, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration) - { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_InvalidConfiguration_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Slack; - model.Configuration = null; - model.Template = "Template String"; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_InvalidTemplate_ThrowsBadRequestException( - SutProvider sutProvider, - Guid organizationId, - OrganizationIntegration organizationIntegration, - OrganizationIntegrationConfiguration organizationIntegrationConfiguration, - OrganizationIntegrationConfigurationRequestModel model) - { - organizationIntegration.OrganizationId = organizationId; - organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; - organizationIntegration.Type = IntegrationType.Slack; - var slackConfig = new SlackIntegrationConfiguration(ChannelId: "C123456"); - model.Configuration = JsonSerializer.Serialize(slackConfig); - model.Template = null; - - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegration); - sutProvider.GetDependency() - .GetByIdAsync(Arg.Any()) - .Returns(organizationIntegrationConfiguration); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - organizationIntegration.Id, - organizationIntegrationConfiguration.Id, - model)); - } - - [Theory, BitAutoData] - public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + Guid integrationId, + Guid configurationId) { sutProvider.Sut.Url = Substitute.For(); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.UpdateAsync( - organizationId, - Guid.Empty, - Guid.Empty, - new OrganizationIntegrationConfigurationRequestModel())); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, new OrganizationIntegrationConfigurationRequestModel())); } } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs deleted file mode 100644 index 8a75db9da8..0000000000 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System.Text.Json; -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.Enums; -using Xunit; - -namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; - -public class OrganizationIntegrationConfigurationRequestModelTests -{ - [Fact] - public void IsValidForType_CloudBillingSyncIntegration_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{}", - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.CloudBillingSync)); - } - - [Theory] - [InlineData(data: null)] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyConfiguration_ReturnsFalse(string? config) - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); - Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); - } - - [Theory] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyNonNullConfiguration_ReturnsFalse(string? config) - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); - } - - [Fact] - public void IsValidForType_NullConfiguration_ReturnsTrue() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = null, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.True(condition: model.IsValidForType(IntegrationType.Teams)); - } - - [Theory] - [InlineData(data: null)] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyTemplate_ReturnsFalse(string? template) - { - var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( - Uri: new Uri("https://localhost"), - Scheme: "Bearer", - Token: "AUTH-TOKEN")); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = template - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); - Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); - Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); - } - - [Fact] - public void IsValidForType_InvalidJsonConfiguration_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{not valid json}", - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); - Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); - Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); - } - - - [Fact] - public void IsValidForType_InvalidJsonFilters_ReturnsFalse() - { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Filters = "{Not valid json", - Template = "template" - }; - - Assert.False(model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_ScimIntegration_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{}", - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Scim)); - } - - [Fact] - public void IsValidForType_ValidSlackConfiguration_ReturnsTrue() - { - var config = JsonSerializer.Serialize(value: new SlackIntegrationConfiguration(ChannelId: "C12345")); - - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Slack)); - } - - [Fact] - public void IsValidForType_ValidSlackConfigurationWithFilters_ReturnsTrue() - { - var config = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")); - var filters = JsonSerializer.Serialize(new IntegrationFilterGroup() - { - AndOperator = true, - Rules = [ - new IntegrationFilterRule() - { - Operation = IntegrationFilterOperation.Equals, - Property = "CollectionId", - Value = Guid.NewGuid() - } - ], - Groups = [] - }); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Filters = filters, - Template = "template" - }; - - Assert.True(model.IsValidForType(IntegrationType.Slack)); - } - - [Fact] - public void IsValidForType_ValidNoAuthWebhookConfiguration_ReturnsTrue() - { - var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"))); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_ValidWebhookConfiguration_ReturnsTrue() - { - var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( - Uri: new Uri("https://localhost"), - Scheme: "Bearer", - Token: "AUTH-TOKEN")); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_ValidWebhookConfigurationWithFilters_ReturnsTrue() - { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( - Uri: new Uri("https://example.com"), - Scheme: "Bearer", - Token: "AUTH-TOKEN")); - var filters = JsonSerializer.Serialize(new IntegrationFilterGroup() - { - AndOperator = true, - Rules = [ - new IntegrationFilterRule() - { - Operation = IntegrationFilterOperation.Equals, - Property = "CollectionId", - Value = Guid.NewGuid() - } - ], - Groups = [] - }); - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Filters = filters, - Template = "template" - }; - - Assert.True(model.IsValidForType(IntegrationType.Webhook)); - } - - [Fact] - public void IsValidForType_UnknownIntegrationType_ReturnsFalse() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = "{}", - Template = "template" - }; - - var unknownType = (IntegrationType)999; - - Assert.False(condition: model.IsValidForType(unknownType)); - } -} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs index f69a61a322..4711426677 100644 --- a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Services; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -32,6 +34,7 @@ public class EventIntegrationServiceCollectionExtensionsTests // Mock required repository dependencies for commands _services.TryAddScoped(_ => Substitute.For()); + _services.TryAddScoped(_ => Substitute.For()); _services.TryAddScoped(_ => Substitute.For()); } @@ -45,6 +48,9 @@ public class EventIntegrationServiceCollectionExtensionsTests var cache = provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName); Assert.NotNull(cache); + var validator = provider.GetRequiredService(); + Assert.NotNull(validator); + using var scope = provider.CreateScope(); var sp = scope.ServiceProvider; @@ -52,6 +58,11 @@ public class EventIntegrationServiceCollectionExtensionsTests Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); Assert.NotNull(sp.GetService()); + + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); } [Fact] @@ -61,8 +72,11 @@ public class EventIntegrationServiceCollectionExtensionsTests var createIntegrationDescriptor = _services.First(s => s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)); + var createConfigDescriptor = _services.First(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)); Assert.Equal(ServiceLifetime.Scoped, createIntegrationDescriptor.Lifetime); + Assert.Equal(ServiceLifetime.Scoped, createConfigDescriptor.Lifetime); } [Fact] @@ -117,7 +131,7 @@ public class EventIntegrationServiceCollectionExtensionsTests _services.AddEventIntegrationsCommandsQueries(_globalSettings); var createConfigCmdDescriptors = _services.Where(s => - s.ServiceType == typeof(ICreateOrganizationIntegrationCommand)).ToList(); + s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)).ToList(); Assert.Single(createConfigCmdDescriptors); var updateIntegrationCmdDescriptors = _services.Where(s => @@ -148,6 +162,29 @@ public class EventIntegrationServiceCollectionExtensionsTests Assert.Single(createCmdDescriptors); } + [Fact] + public void AddOrganizationIntegrationConfigurationCommandsQueries_RegistersAllConfigurationServices() + { + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + + Assert.Contains(_services, s => s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IUpdateOrganizationIntegrationConfigurationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IDeleteOrganizationIntegrationConfigurationCommand)); + Assert.Contains(_services, s => s.ServiceType == typeof(IGetOrganizationIntegrationConfigurationsQuery)); + } + + [Fact] + public void AddOrganizationIntegrationConfigurationCommandsQueries_MultipleCalls_IsIdempotent() + { + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + _services.AddOrganizationIntegrationConfigurationCommandsQueries(); + + var createCmdDescriptors = _services.Where(s => + s.ServiceType == typeof(ICreateOrganizationIntegrationConfigurationCommand)).ToList(); + Assert.Single(createCmdDescriptors); + } + private static GlobalSettings CreateGlobalSettings(Dictionary data) { var config = new ConfigurationBuilder() diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs new file mode 100644 index 0000000000..c6c8a44955 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommandTests.cs @@ -0,0 +1,179 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class CreateOrganizationIntegrationConfigurationCommandTests +{ + [Theory, BitAutoData] + public async Task CreateAsync_Success_CreatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = EventType.User_LoggedIn; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .CreateAsync(configuration) + .Returns(configuration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .CreateAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + configuration.EventType.Value)); + // Also verify RemoveByTagAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + Assert.Equal(configuration, result); + } + + [Theory, BitAutoData] + public async Task CreateAsync_WildcardSuccess_CreatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = null; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .CreateAsync(configuration) + .Returns(configuration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .CreateAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + // Also verify RemoveAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + Assert.Equal(configuration, result); + } + + [Theory, BitAutoData] + public async Task CreateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegrationConfiguration configuration) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration)); + + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration)); + + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAsync_ValidationFails_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(false); + + integration.Id = integrationId; + integration.OrganizationId = organizationId; + configuration.OrganizationIntegrationId = integrationId; + configuration.Template = "template"; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAsync(organizationId, integrationId, configuration)); + + await sutProvider.GetDependency().DidNotReceive() + .CreateAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs new file mode 100644 index 0000000000..3b12f4bd88 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommandTests.cs @@ -0,0 +1,211 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class DeleteOrganizationIntegrationConfigurationCommandTests +{ + [Theory, BitAutoData] + public async Task DeleteAsync_Success_DeletesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.Id = configurationId; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = EventType.User_LoggedIn; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(configuration); + + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + configuration.EventType.Value)); + // Also verify RemoveByTagAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_WildcardSuccess_DeletesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + configuration.Id = configurationId; + configuration.OrganizationIntegrationId = integrationId; + configuration.EventType = null; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(configuration); + + await sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(configuration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + // Also verify RemoveAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ConfigurationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns((OrganizationIntegrationConfiguration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DeleteAsync_ConfigurationDoesNotBelongToIntegration_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration configuration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + configuration.Id = configurationId; + configuration.OrganizationIntegrationId = Guid.NewGuid(); // Different integration + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(configuration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DeleteAsync(organizationId, integrationId, configurationId)); + + await sutProvider.GetDependency().DidNotReceive() + .DeleteAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs new file mode 100644 index 0000000000..18541df53e --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQueryTests.cs @@ -0,0 +1,101 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +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.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class GetOrganizationIntegrationConfigurationsQueryTests +{ + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_Success_ReturnsConfigurations( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + List configurations) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(integrationId) + .Returns(configurations); + + var result = await sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(integrationId); + Assert.Equal(configurations.Count, result.Count); + } + + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_NoConfigurations_ReturnsEmptyList( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(integrationId) + .Returns([]); + + var result = await sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId); + + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetManyByIntegrationAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetManyByIntegrationAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetManyByIntegrationAsync(organizationId, integrationId)); + + await sutProvider.GetDependency().DidNotReceive() + .GetManyByIntegrationAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs new file mode 100644 index 0000000000..c2eeefc087 --- /dev/null +++ b/test/Core.Test/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommandTests.cs @@ -0,0 +1,390 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +[SutProviderCustomize] +public class UpdateOrganizationIntegrationConfigurationCommandTests +{ + [Theory, BitAutoData] + public async Task UpdateAsync_Success_UpdatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedConfiguration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + existingConfiguration.EventType.Value)); + // Also verify RemoveByTagAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + Assert.Equal(updatedConfiguration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_WildcardSuccess_UpdatesConfigurationAndInvalidatesCache( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = null; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.EventType = null; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedConfiguration); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + // Also verify RemoveAsync was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + Assert.Equal(updatedConfiguration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ChangedEventType_UpdatesConfigurationAndInvalidatesCacheForBothTypes( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + integration.Type = IntegrationType.Webhook; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.EventType = EventType.Cipher_Created; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(integrationId); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(configurationId); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(updatedConfiguration); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + existingConfiguration.EventType.Value)); + await sutProvider.GetDependency().Received(1) + .RemoveAsync(EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId, + integration.Type, + updatedConfiguration.EventType.Value)); + // Verify RemoveByTagAsync was NOT called since both are specific event types + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + Assert.Equal(updatedConfiguration, result); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration updatedConfiguration) + { + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns((OrganizationIntegration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = Guid.NewGuid(); // Different organization + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ConfigurationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns((OrganizationIntegrationConfiguration)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ConfigurationDoesNotBelongToIntegration_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + integration.Id = integrationId; + integration.OrganizationId = organizationId; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = Guid.NewGuid(); // Different integration + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ValidationFails_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration) + { + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(false); + + integration.Id = integrationId; + integration.OrganizationId = organizationId; + existingConfiguration.Id = configurationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.Id = configurationId; + updatedConfiguration.OrganizationIntegrationId = integrationId; + updatedConfiguration.Template = "template"; + + sutProvider.GetDependency() + .GetByIdAsync(integrationId) + .Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(configurationId) + .Returns(existingConfiguration); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateAsync(organizationId, integrationId, configurationId, updatedConfiguration)); + + await sutProvider.GetDependency().DidNotReceive() + .ReplaceAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .RemoveByTagAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ChangedFromWildcardToSpecific_InvalidatesAllCaches( + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration, + SutProvider sutProvider) + { + integration.OrganizationId = organizationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = null; // Wildcard + updatedConfiguration.EventType = EventType.User_LoggedIn; // Specific + + sutProvider.GetDependency() + .GetByIdAsync(integrationId).Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(existingConfiguration.Id).Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, existingConfiguration.Id, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_ChangedFromSpecificToWildcard_InvalidatesAllCaches( + Guid organizationId, + Guid integrationId, + OrganizationIntegration integration, + OrganizationIntegrationConfiguration existingConfiguration, + OrganizationIntegrationConfiguration updatedConfiguration, + SutProvider sutProvider) + { + integration.OrganizationId = organizationId; + existingConfiguration.OrganizationIntegrationId = integrationId; + existingConfiguration.EventType = EventType.User_LoggedIn; // Specific + updatedConfiguration.EventType = null; // Wildcard + + sutProvider.GetDependency() + .GetByIdAsync(integrationId).Returns(integration); + sutProvider.GetDependency() + .GetByIdAsync(existingConfiguration.Id).Returns(existingConfiguration); + sutProvider.GetDependency() + .ValidateConfiguration(Arg.Any(), Arg.Any()) + .Returns(true); + + await sutProvider.Sut.UpdateAsync(organizationId, integrationId, existingConfiguration.Id, updatedConfiguration); + + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integration.Type)); + await sutProvider.GetDependency().DidNotReceive() + .RemoveAsync(Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs new file mode 100644 index 0000000000..1154ad8025 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/OrganizationIntegrationConfigurationValidatorTests.cs @@ -0,0 +1,244 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Services; + +public class OrganizationIntegrationConfigurationValidatorTests +{ + private readonly OrganizationIntegrationConfigurationValidator _sut = new(); + + [Fact] + public void ValidateConfiguration_CloudBillingSyncIntegration_ReturnsFalse() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.CloudBillingSync, configuration)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateConfiguration_EmptyTemplate_ReturnsFalse(string? template) + { + var config1 = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new SlackIntegrationConfiguration(ChannelId: "C12345")), + Template = template + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Slack, config1)); + + var config2 = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))), + Template = template + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Webhook, config2)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ValidateConfiguration_EmptyNonNullConfiguration_ReturnsFalse(string? config) + { + var config1 = new OrganizationIntegrationConfiguration + { + Configuration = config, + Template = "template" + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Hec, config1)); + + var config2 = new OrganizationIntegrationConfiguration + { + Configuration = config, + Template = "template" + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Datadog, config2)); + + var config3 = new OrganizationIntegrationConfiguration + { + Configuration = config, + Template = "template" + }; + Assert.False(_sut.ValidateConfiguration(IntegrationType.Teams, config3)); + } + + [Fact] + public void ValidateConfiguration_NullConfiguration_ReturnsTrue() + { + var config1 = new OrganizationIntegrationConfiguration + { + Configuration = null, + Template = "template" + }; + Assert.True(_sut.ValidateConfiguration(IntegrationType.Hec, config1)); + + var config2 = new OrganizationIntegrationConfiguration + { + Configuration = null, + Template = "template" + }; + Assert.True(_sut.ValidateConfiguration(IntegrationType.Datadog, config2)); + + var config3 = new OrganizationIntegrationConfiguration + { + Configuration = null, + Template = "template" + }; + Assert.True(_sut.ValidateConfiguration(IntegrationType.Teams, config3)); + } + + [Fact] + public void ValidateConfiguration_InvalidJsonConfiguration_ReturnsFalse() + { + var config = new OrganizationIntegrationConfiguration + { + Configuration = "{not valid json}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.Slack, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Webhook, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Hec, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Datadog, config)); + Assert.False(_sut.ValidateConfiguration(IntegrationType.Teams, config)); + } + + [Fact] + public void ValidateConfiguration_InvalidJsonFilters_ReturnsFalse() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))), + Template = "template", + Filters = "{Not valid json}" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_ScimIntegration_ReturnsFalse() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(IntegrationType.Scim, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidSlackConfiguration_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new SlackIntegrationConfiguration(ChannelId: "C12345")), + Template = "template" + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Slack, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidSlackConfigurationWithFilters_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")), + Template = "template", + Filters = JsonSerializer.Serialize(new IntegrationFilterGroup() + { + AndOperator = true, + Rules = [ + new IntegrationFilterRule() + { + Operation = IntegrationFilterOperation.Equals, + Property = "CollectionId", + Value = Guid.NewGuid() + } + ], + Groups = [] + }) + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Slack, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidNoAuthWebhookConfiguration_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"))), + Template = "template" + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidWebhookConfiguration_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( + Uri: new Uri("https://localhost"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")), + Template = "template" + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_ValidWebhookConfigurationWithFilters_ReturnsTrue() + { + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( + Uri: new Uri("https://example.com"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")), + Template = "template", + Filters = JsonSerializer.Serialize(new IntegrationFilterGroup() + { + AndOperator = true, + Rules = [ + new IntegrationFilterRule() + { + Operation = IntegrationFilterOperation.Equals, + Property = "CollectionId", + Value = Guid.NewGuid() + } + ], + Groups = [] + }) + }; + + Assert.True(_sut.ValidateConfiguration(IntegrationType.Webhook, configuration)); + } + + [Fact] + public void ValidateConfiguration_UnknownIntegrationType_ReturnsFalse() + { + var unknownType = (IntegrationType)999; + var configuration = new OrganizationIntegrationConfiguration + { + Configuration = "{}", + Template = "template" + }; + + Assert.False(_sut.ValidateConfiguration(unknownType, configuration)); + } +} diff --git a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs index f6084c9209..a87392c2c1 100644 --- a/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs +++ b/test/Core.Test/Utilities/EventIntegrationsCacheConstantsTests.cs @@ -55,20 +55,6 @@ public class EventIntegrationsCacheConstantsTests Assert.NotEqual(keyWithEvent, keyWithDifferentIntegration); Assert.NotEqual(keyWithEvent, keyWithDifferentOrganization); Assert.Equal(keyWithEvent, keyWithSameDetails); - - var expectedWithNullEvent = $"OrganizationIntegrationConfigurationDetails:{orgId:N}:Hec:"; - var keyWithNullEvent = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( - orgId, integrationType, null); - var keyWithNullEventDifferentIntegration = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( - orgId, IntegrationType.Webhook, null); - var keyWithNullEventDifferentOrganization = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( - Guid.NewGuid(), integrationType, null); - - Assert.Equal(expectedWithNullEvent, keyWithNullEvent); - Assert.NotEqual(keyWithEvent, keyWithNullEvent); - Assert.NotEqual(keyWithNullEvent, keyWithDifferentEvent); - Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentIntegration); - Assert.NotEqual(keyWithNullEvent, keyWithNullEventDifferentOrganization); } [Theory, BitAutoData] From 84b138f4314143ce8142ad5357c2bdc5e2397224 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:26:29 -0800 Subject: [PATCH 02/47] (Auth) [PM-27108] Add OrgId checks in SSO Process (#6710) --- .../src/Sso/Controllers/AccountController.cs | 30 ++++- .../Controllers/AccountControllerTest.cs | 127 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index bc26fb270a..7141f8429d 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -201,12 +201,15 @@ public class AccountController : Controller returnUrl, state = context.Parameters["state"], userIdentifier = context.Parameters["session_state"], + ssoToken }); } [HttpGet] - public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier) + public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier, string ssoToken) { + ValidateSchemeAgainstSsoToken(scheme, ssoToken); + if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "~/"; @@ -235,6 +238,31 @@ public class AccountController : Controller return Challenge(props, scheme); } + /// + /// Validates the scheme (organization ID) against the organization ID found in the ssoToken. + /// + /// The authentication scheme (organization ID) to validate. + /// The SSO token to validate against. + /// Thrown if the scheme (organization ID) does not match the organization ID found in the ssoToken. + private void ValidateSchemeAgainstSsoToken(string scheme, string ssoToken) + { + SsoTokenable tokenable; + + try + { + tokenable = _dataProtector.Unprotect(ssoToken); + } + catch + { + throw new Exception(_i18nService.T("InvalidSsoToken")); + } + + if (!Guid.TryParse(scheme, out var schemeOrgId) || tokenable.OrganizationId != schemeOrgId) + { + throw new Exception(_i18nService.T("SsoOrganizationIdMismatch")); + } + } + [HttpGet] public async Task ExternalCallback() { diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index c04948e21f..b276174814 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Registration; @@ -10,6 +11,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Sso.Controllers; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -1137,4 +1139,129 @@ public class AccountControllerTest Assert.NotNull(result.user); Assert.Equal(email, result.user.Email); } + + [Theory, BitAutoData] + public void ExternalChallenge_WithMatchingOrgId_Succeeds( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var orgId = organization.Id; + var scheme = orgId.ToString(); + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a tokenable with matching org ID + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock URL helper for IsLocalUrl check + var urlHelper = Substitute.For(); + urlHelper.IsLocalUrl(returnUrl).Returns(true); + sutProvider.Sut.Url = urlHelper; + + // Mock interaction service for IsValidReturnUrl check + var interactionService = sutProvider.GetDependency(); + interactionService.IsValidReturnUrl(returnUrl).Returns(true); + + // Act + var result = sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken); + + // Assert + var challengeResult = Assert.IsType(result); + Assert.Contains(scheme, challengeResult.AuthenticationSchemes); + Assert.NotNull(challengeResult.Properties); + Assert.Equal(scheme, challengeResult.Properties.Items["scheme"]); + Assert.Equal(returnUrl, challengeResult.Properties.Items["return_url"]); + Assert.Equal(state, challengeResult.Properties.Items["state"]); + Assert.Equal(userIdentifier, challengeResult.Properties.Items["user_identifier"]); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithMismatchedOrgId_ThrowsSsoOrganizationIdMismatch( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var correctOrgId = organization.Id; + var wrongOrgId = Guid.NewGuid(); + var scheme = wrongOrgId.ToString(); // Different from tokenable's org ID + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a tokenable with different org ID + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); // Contains correctOrgId + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("SsoOrganizationIdMismatch", ex.Message); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithInvalidSchemeFormat_ThrowsSsoOrganizationIdMismatch( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var scheme = "not-a-valid-guid"; + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a valid tokenable + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("SsoOrganizationIdMismatch", ex.Message); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithInvalidSsoToken_ThrowsInvalidSsoToken( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var scheme = orgId.ToString(); + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "invalid-corrupted-token"; + + // Mock the data protector to throw when trying to unprotect + var dataProtector = sutProvider.GetDependency>(); + dataProtector.Unprotect(ssoToken).Returns(_ => throw new Exception("Token validation failed")); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("InvalidSsoToken", ex.Message); + } } From 5ac8536855b38c2f250c14160f192bd5146ee590 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Fri, 12 Dec 2025 20:19:09 +0100 Subject: [PATCH 03/47] [PM-28662] Fix Individual Premium automatically disabled due to duplicate subscription leftover from failed payment (#6663) * Fix the Bug * Address the hardcode issue * Fix the tailing test * resolve the lint issue --- .../SubscriptionUpdatedHandler.cs | 24 ++- .../SubscriptionUpdatedHandlerTests.cs | 203 +++++++++++++++++- 2 files changed, 223 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 07ffef064f..c10368d8c0 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -109,8 +109,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler break; } - if (subscription.Status is StripeSubscriptionStatus.Unpaid && - subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore)) + if (await IsPremiumSubscriptionAsync(subscription)) { await CancelSubscription(subscription.Id); await VoidOpenInvoices(subscription.Id); @@ -118,6 +117,20 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + break; + } + case StripeSubscriptionStatus.Incomplete when userId.HasValue: + { + // Handle Incomplete subscriptions for Premium users that have open invoices from failed payments + // This prevents duplicate subscriptions when users retry the subscription flow + if (await IsPremiumSubscriptionAsync(subscription) && + subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open }) + { + await CancelSubscription(subscription.Id); + await VoidOpenInvoices(subscription.Id); + await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + } + break; } case StripeSubscriptionStatus.Active when organizationId.HasValue: @@ -190,6 +203,13 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } + private async Task IsPremiumSubscriptionAsync(Subscription subscription) + { + var premiumPlans = await _pricingClient.ListPremiumPlans(); + var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet(); + return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id)); + } + /// /// Checks if the provider subscription status has changed from a non-active to an active status type /// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false. diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 4a480f8c30..182f09e163 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -20,6 +20,8 @@ using Quartz; using Stripe; using Xunit; using Event = Stripe.Event; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; namespace Bit.Billing.Test.Services; @@ -400,6 +402,75 @@ public class SubscriptionUpdatedHandlerTests var parsedEvent = new Event { Data = new EventData() }; + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + }; + _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + _stripeFacade.ListInvoices(Arg.Any()) + .Returns(new StripeList { Data = new List() }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1) + .DisablePremiumAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1) + .CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1) + .ListInvoices(Arg.Is(o => + o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId)); + } + + [Fact] + public async Task HandleAsync_IncompleteExpiredUserSubscription_DisablesPremiumAndCancelsSubscription() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.IncompleteExpired, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } + } + ] + } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + }; + _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(subscription); @@ -565,7 +636,7 @@ public class SubscriptionUpdatedHandlerTests new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), - Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + Plan = new Stripe.Plan { Id = "2023-enterprise-org-seat-annually" } } ] }, @@ -599,7 +670,7 @@ public class SubscriptionUpdatedHandlerTests { Data = [ - new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } } + new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-enterprise-seat-annually" } } ] } }) @@ -933,6 +1004,134 @@ public class SubscriptionUpdatedHandlerTests return (providerId, newSubscription, provider, parsedEvent); } + [Fact] + public async Task HandleAsync_IncompleteUserSubscriptionWithOpenInvoice_CancelsSubscriptionAndDisablesPremium() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var openInvoice = new Invoice + { + Id = "inv_123", + Status = StripeInvoiceStatus.Open + }; + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Incomplete, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + LatestInvoice = openInvoice, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } + } + ] + } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + }; + _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + _stripeFacade.ListInvoices(Arg.Any()) + .Returns(new StripeList { Data = new List { openInvoice } }); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.Received(1) + .DisablePremiumAsync(userId, currentPeriodEnd); + await _stripeFacade.Received(1) + .CancelSubscription(subscriptionId, Arg.Any()); + await _stripeFacade.Received(1) + .ListInvoices(Arg.Is(o => + o.Status == StripeInvoiceStatus.Open && o.Subscription == subscriptionId)); + await _stripeFacade.Received(1) + .VoidInvoice(openInvoice.Id); + } + + [Fact] + public async Task HandleAsync_IncompleteUserSubscriptionWithoutOpenInvoice_DoesNotCancelSubscription() + { + // Arrange + var userId = Guid.NewGuid(); + var subscriptionId = "sub_123"; + var currentPeriodEnd = DateTime.UtcNow.AddDays(30); + var paidInvoice = new Invoice + { + Id = "inv_123", + Status = StripeInvoiceStatus.Paid + }; + var subscription = new Subscription + { + Id = subscriptionId, + Status = StripeSubscriptionStatus.Incomplete, + Metadata = new Dictionary { { "userId", userId.ToString() } }, + LatestInvoice = paidInvoice, + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } + } + ] + } + }; + + var parsedEvent = new Event { Data = new EventData() }; + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = IStripeEventUtilityService.PremiumPlanId }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "storage-plan-personal" } + }; + _pricingClient.ListPremiumPlans().Returns(new List { premiumPlan }); + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userService.DidNotReceive() + .DisablePremiumAsync(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .CancelSubscription(Arg.Any(), Arg.Any()); + await _stripeFacade.DidNotReceive() + .ListInvoices(Arg.Any()); + } + public static IEnumerable GetNonActiveSubscriptions() { return new List From 4fdc4b1b49b45090b81a6ab14b6feadd1de1cf43 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:38:21 -0500 Subject: [PATCH 04/47] refactor(base-request-validator) [PM-28621] Unwind pm-21153 Feature Flag (#6730) * refactor(base-request-validator) [PM-28621]: Remove feature flagged logic and constant. * refactor(base-request-validator) [PM-28621]: Update tests to reflect unwound feature flag. --- src/Core/Constants.cs | 1 - .../RequestValidators/BaseRequestValidator.cs | 171 +------- .../RequestValidators/SsoRequestValidator.cs | 6 +- .../BaseRequestValidatorTests.cs | 405 +++++++----------- 4 files changed, 172 insertions(+), 411 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3710cb4a23..6d2c2a1673 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -160,7 +160,6 @@ public static class FeatureFlagKeys public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; - public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; 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"; diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 429c16a6b3..0bdf1d89c2 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -97,155 +97,16 @@ public abstract class BaseRequestValidator where T : class protected async Task ValidateAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) + var validators = DetermineValidationOrder(context, request, validatorContext); + var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators); + if (!allValidationSchemesSuccessful) { - var validators = DetermineValidationOrder(context, request, validatorContext); - var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators); - if (!allValidationSchemesSuccessful) - { - // Each validation task is responsible for setting its own non-success status, if applicable. - return; - } - await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.Device, - validatorContext.RememberMeRequested); + // Each validation task is responsible for setting its own non-success status, if applicable. + return; } - else - { - // 1. We need to check if the user's master password hash is correct. - var valid = await ValidateContextAsync(context, validatorContext); - var user = validatorContext.User; - if (!valid) - { - await UpdateFailedAuthDetailsAsync(user); - await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); - return; - } - - // 2. Decide if this user belongs to an organization that requires SSO. - // TODO: Clean up Feature Flag: Remove this if block: PM-28281 - if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) - { - 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. - (validatorContext.TwoFactorRequired, var twoFactorOrganization) = - await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request); - - // This flag is used to determine if the user wants a rememberMe token sent when - // authentication is successful. - var returnRememberMeToken = false; - - if (validatorContext.TwoFactorRequired) - { - var twoFactorToken = request.Raw["TwoFactorToken"]; - var twoFactorProvider = request.Raw["TwoFactorProvider"]; - var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && - !string.IsNullOrWhiteSpace(twoFactorProvider); - - // 3a. Response for 2FA required and not provided state. - if (!validTwoFactorRequest || - !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) - { - var resultDict = await _twoFactorAuthenticationValidator - .BuildTwoFactorResultAsync(user, twoFactorOrganization); - if (resultDict == null) - { - await BuildErrorResultAsync("No two-step providers enabled.", false, context, user); - return; - } - - // Include Master Password Policy in 2FA response. - resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); - SetTwoFactorResult(context, resultDict); - return; - } - - var twoFactorTokenValid = - await _twoFactorAuthenticationValidator - .VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); - - // 3b. Response for 2FA required but request is not valid or remember token expired state. - if (!twoFactorTokenValid) - { - // The remember me token has expired. - if (twoFactorProviderType == TwoFactorProviderType.Remember) - { - var resultDict = await _twoFactorAuthenticationValidator - .BuildTwoFactorResultAsync(user, twoFactorOrganization); - - // Include Master Password Policy in 2FA response - resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); - SetTwoFactorResult(context, resultDict); - } - else - { - await SendFailedTwoFactorEmail(user, twoFactorProviderType); - await UpdateFailedAuthDetailsAsync(user); - await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user); - } - - return; - } - - // 3c. When the 2FA authentication is successful, we can check if the user wants a - // rememberMe token. - var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1"; - // Check if the user wants a rememberMe token. - if (twoFactorRemember - // if the 2FA auth was rememberMe do not send another token. - && twoFactorProviderType != TwoFactorProviderType.Remember) - { - returnRememberMeToken = true; - } - } - - // 4. Check if the user is logging in from a new device. - var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext); - if (!deviceValid) - { - SetValidationErrorResult(context, validatorContext); - await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn); - return; - } - - // 5. Force legacy users to the web for migration. - if (UserService.IsLegacyUser(user) && request.ClientId != "web") - { - await FailAuthForLegacyUserAsync(user, context); - return; - } - - // TODO: PM-24324 - This should be its own validator at some point. - // 6. Auth request handling - if (validatorContext.ValidatedAuthRequest != null) - { - validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow; - await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest); - } - - await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken); - } + await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.Device, + validatorContext.RememberMeRequested); } protected async Task FailAuthForLegacyUserAsync(User user, T context) @@ -392,17 +253,22 @@ public abstract class BaseRequestValidator where T : class if (validatorContext.TwoFactorRequired && validatorContext.TwoFactorRecoveryRequested) { - SetSsoResult(context, new Dictionary - { - { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } - }); + 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.") } + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } }); return false; } @@ -683,7 +549,8 @@ 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.")] + [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") diff --git a/src/Identity/IdentityServer/RequestValidators/SsoRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SsoRequestValidator.cs index 81f8ba1c3f..145ecc8737 100644 --- a/src/Identity/IdentityServer/RequestValidators/SsoRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SsoRequestValidator.cs @@ -48,8 +48,6 @@ public class SsoRequestValidator( // 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); @@ -63,10 +61,10 @@ public class SsoRequestValidator( /// /// 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 + /// If the GrantType is authorization_code or client_credentials we know the user is trying to log in /// using the SSO flow so they are allowed to continue. /// - /// user trying to login + /// user trying to log in /// 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) diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 2ead26e4d2..677382b138 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -104,13 +104,6 @@ public class BaseRequestValidatorTests _userAccountKeysQuery); } - private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(bool recoveryCodeSupportEnabled) - { - _featureService - .IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers) - .Returns(recoveryCodeSupportEnabled); - } - /* Logic path * ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync @@ -118,16 +111,14 @@ public class BaseRequestValidatorTests * |-> SetErrorResult */ [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _globalSettings.SelfHosted = true; _sut.isValid = false; @@ -144,16 +135,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_DeviceNotValidated_ShouldLogError( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass @@ -186,16 +175,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_DeviceValidated_ShouldSucceed( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass @@ -232,16 +219,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass @@ -297,16 +282,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass @@ -329,7 +312,8 @@ 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\"}}}"; + requestContext.User.TwoFactorProviders = + "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"user@test.dev\"}}}"; _twoFactorAuthenticationValidator .RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(true, null))); @@ -339,7 +323,7 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Dictionary { { "TwoFactorProviders", new[] { "0", "1" } }, - { "TwoFactorProviders2", new Dictionary{{"Email", null}} } + { "TwoFactorProviders2", new Dictionary { { "Email", null } } } })); // Act @@ -356,16 +340,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -400,16 +382,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -455,21 +435,17 @@ public class BaseRequestValidatorTests // Test grantTypes that require SSO when a user is in an organization that requires it [Theory] - [BitAutoData("password", true)] - [BitAutoData("password", false)] - [BitAutoData("webauthn", true)] - [BitAutoData("webauthn", false)] - [BitAutoData("refresh_token", true)] - [BitAutoData("refresh_token", false)] + [BitAutoData("password")] + [BitAutoData("webauthn")] + [BitAutoData("refresh_token")] public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult( string grantType, - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -489,21 +465,17 @@ public class BaseRequestValidatorTests // Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled [Theory] - [BitAutoData("password", true)] - [BitAutoData("password", false)] - [BitAutoData("webauthn", true)] - [BitAutoData("webauthn", false)] - [BitAutoData("refresh_token", true)] - [BitAutoData("refresh_token", false)] + [BitAutoData("password")] + [BitAutoData("webauthn")] + [BitAutoData("refresh_token")] public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult( string grantType, - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -525,21 +497,17 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData("password", true)] - [BitAutoData("password", false)] - [BitAutoData("webauthn", true)] - [BitAutoData("webauthn", false)] - [BitAutoData("refresh_token", true)] - [BitAutoData("refresh_token", false)] + [BitAutoData("password")] + [BitAutoData("webauthn")] + [BitAutoData("refresh_token")] public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed( string grantType, - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -574,21 +542,17 @@ public class BaseRequestValidatorTests // Test grantTypes where SSO would be required but the user is not in an // organization that requires it [Theory] - [BitAutoData("password", true)] - [BitAutoData("password", false)] - [BitAutoData("webauthn", true)] - [BitAutoData("webauthn", false)] - [BitAutoData("refresh_token", true)] - [BitAutoData("refresh_token", false)] + [BitAutoData("password")] + [BitAutoData("webauthn")] + [BitAutoData("refresh_token")] public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed( string grantType, - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -623,19 +587,17 @@ public class BaseRequestValidatorTests // Test the grantTypes where SSO is in progress or not relevant [Theory] - [BitAutoData("authorization_code", true)] - [BitAutoData("authorization_code", false)] - [BitAutoData("client_credentials", true)] - [BitAutoData("client_credentials", false)] + [BitAutoData("authorization_code")] + [BitAutoData("client_credentials")] + [BitAutoData("client_credentials")] public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed( string grantType, - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -671,16 +633,14 @@ public class BaseRequestValidatorTests * ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync */ [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = context.CustomValidatorRequestContext.User; user.Key = null; @@ -705,16 +665,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); @@ -755,19 +713,16 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true, KdfType.PBKDF2_SHA256, 654_321, null, null)] - [BitAutoData(false, KdfType.PBKDF2_SHA256, 654_321, null, null)] - [BitAutoData(true, KdfType.Argon2id, 11, 128, 5)] - [BitAutoData(false, KdfType.Argon2id, 11, 128, 5)] + [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(KdfType.Argon2id, 11, 128, 5)] public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions( - bool featureFlagValue, KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); @@ -826,16 +781,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var mockAccountKeys = new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -908,16 +861,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); requestContext.User.PrivateKey = null; var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -938,16 +889,14 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser( - bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var expectedUser = requestContext.User; _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData @@ -987,22 +936,20 @@ public class BaseRequestValidatorTests /// Tests the core PM-21153 feature: SSO-required users can use recovery codes to disable 2FA, /// but must then authenticate via SSO with a descriptive message about the recovery. /// This test validates: - /// 1. Validation order is changed (2FA before SSO) when recovery code is provided + /// 1. Validation order prioritizes 2FA before SSO when recovery code is provided /// 2. Recovery code successfully validates and sets TwoFactorRecoveryRequested flag /// 3. SSO validation then fails with recovery-specific message /// 4. User is NOT logged in (must authenticate via IdP) /// [Theory] - [BitAutoData(true)] // Feature flag ON - new behavior - [BitAutoData(false)] // Feature flag OFF - should fail at SSO before 2FA recovery + [BitAutoData] public async Task ValidateAsync_RecoveryCodeForSsoRequiredUser_BlocksWithDescriptiveMessage( - bool featureFlagEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -1015,8 +962,8 @@ public class BaseRequestValidatorTests // 2. SSO is required (this user is in an org that requires SSO) _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(true)); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); // 3. 2FA is required _twoFactorAuthenticationValidator @@ -1040,30 +987,16 @@ public class BaseRequestValidatorTests var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - if (featureFlagEnabled) - { - // NEW BEHAVIOR: Recovery succeeds, then SSO blocks with descriptive message - Assert.Equal( - "Two-factor recovery has been performed. SSO authentication is required.", - errorResponse.Message); + // Recovery succeeds, then SSO blocks with descriptive message + Assert.Equal( + "Two-factor recovery has been performed. SSO authentication is required.", + errorResponse.Message); - // Verify recovery was marked - Assert.True(requestContext.TwoFactorRecoveryRequested, - "TwoFactorRecoveryRequested flag should be set"); - } - else - { - // LEGACY BEHAVIOR: SSO blocks BEFORE recovery can happen - Assert.Equal( - "SSO authentication is required.", - errorResponse.Message); + // Verify recovery was marked + Assert.True(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested flag should be set"); - // Recovery never happened because SSO checked first - Assert.False(requestContext.TwoFactorRecoveryRequested, - "TwoFactorRecoveryRequested should be false (SSO blocked first)"); - } - - // In both cases: User is NOT logged in + // User is NOT logged in await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn); } @@ -1078,16 +1011,14 @@ public class BaseRequestValidatorTests /// 4. NOT be logged in /// [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_InvalidRecoveryCodeForSsoRequiredUser_FailsAt2FA( - bool featureFlagEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -1096,8 +1027,8 @@ public class BaseRequestValidatorTests // 2. SSO is required _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(true)); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); // 3. 2FA is required _twoFactorAuthenticationValidator @@ -1121,51 +1052,32 @@ public class BaseRequestValidatorTests var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - if (featureFlagEnabled) - { - // NEW BEHAVIOR: 2FA is checked first (due to recovery code request), fails with 2FA error - Assert.Equal( - "Two-step token is invalid. Try again.", - errorResponse.Message); + // 2FA is checked first (due to recovery code request), fails with 2FA error + Assert.Equal( + "Two-step token is invalid. Try again.", + errorResponse.Message); - // Recovery was attempted but failed - flag should NOT be set - Assert.False(requestContext.TwoFactorRecoveryRequested, - "TwoFactorRecoveryRequested should be false (recovery failed)"); + // Recovery was attempted but failed - flag should NOT be set + Assert.False(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested should be false (recovery failed)"); - // Verify failed 2FA email was sent - await _mailService.Received(1).SendFailedTwoFactorAttemptEmailAsync( - user.Email, - TwoFactorProviderType.RecoveryCode, - Arg.Any(), - Arg.Any()); + // Verify failed 2FA email was sent + await _mailService.Received(1).SendFailedTwoFactorAttemptEmailAsync( + user.Email, + TwoFactorProviderType.RecoveryCode, + Arg.Any(), + Arg.Any()); - // Verify failed login event was logged - await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_FailedLogIn2fa); - } - else - { - // LEGACY BEHAVIOR: SSO is checked first, blocks before 2FA - Assert.Equal( - "SSO authentication is required.", - errorResponse.Message); + // Verify failed login event was logged + await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_FailedLogIn2fa); - // 2FA validation never happened - await _mailService.DidNotReceive().SendFailedTwoFactorAttemptEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - // In both cases: User is NOT logged in + // User is NOT logged in await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn); // Verify user failed login count was updated (in new behavior path) - if (featureFlagEnabled) - { - await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id && u.FailedLoginCount > 0)); - } + await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id && u.FailedLoginCount > 0)); } /// @@ -1179,16 +1091,14 @@ public class BaseRequestValidatorTests /// This is the "happy path" for recovery code usage. /// [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_RecoveryCodeForNonSsoUser_SuccessfulLogin( - bool featureFlagEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -1197,8 +1107,8 @@ public class BaseRequestValidatorTests // 2. SSO is NOT required (this is a regular user, not in SSO org) _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(false)); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(false)); // 3. 2FA is required _twoFactorAuthenticationValidator @@ -1235,7 +1145,8 @@ public class BaseRequestValidatorTests await _sut.ValidateAsync(context); // Assert - Assert.False(context.GrantResult.IsError, "Authentication should succeed for non-SSO user with valid recovery code"); + Assert.False(context.GrantResult.IsError, + "Authentication should succeed for non-SSO user with valid recovery code"); // Verify user successfully logged in await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_LoggedIn); @@ -1244,19 +1155,9 @@ public class BaseRequestValidatorTests await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => u.Id == user.Id && u.FailedLoginCount == 0)); - if (featureFlagEnabled) - { - // NEW BEHAVIOR: Recovery flag should be set for audit purposes - Assert.True(requestContext.TwoFactorRecoveryRequested, - "TwoFactorRecoveryRequested flag should be set for audit/logging"); - } - else - { - // LEGACY BEHAVIOR: Recovery flag doesn't exist, but login still succeeds - // (SSO check happens before 2FA in legacy, but user is not SSO-required so both pass) - Assert.False(requestContext.TwoFactorRecoveryRequested, - "TwoFactorRecoveryRequested should be false in legacy mode"); - } + // Recovery flag should be set for audit purposes + Assert.True(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested flag should be set for audit/logging"); } /// @@ -1265,16 +1166,14 @@ public class BaseRequestValidatorTests /// is checked using the old PolicyService.AnyPoliciesApplicableToUserAsync approach. /// [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_UsesLegacySsoValidation( - bool recoveryCodeFeatureEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -1284,7 +1183,7 @@ public class BaseRequestValidatorTests // SSO is required via legacy path _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) .Returns(Task.FromResult(true)); // Act @@ -1309,16 +1208,14 @@ public class BaseRequestValidatorTests /// instead of the legacy RequireSsoLoginAsync method. /// [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_UsesNewSsoRequestValidator( - bool recoveryCodeFeatureEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -1328,9 +1225,9 @@ public class BaseRequestValidatorTests // Configure SsoRequestValidator to indicate SSO is required _ssoRequestValidator.ValidateAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(Task.FromResult(false)); // false = SSO required // Set up the ValidationErrorResult that SsoRequestValidator would set @@ -1367,16 +1264,14 @@ public class BaseRequestValidatorTests /// authentication continues successfully through the new validation path. /// [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_SsoNotRequired_SuccessfulLogin( - bool recoveryCodeFeatureEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -1387,9 +1282,9 @@ public class BaseRequestValidatorTests // SsoRequestValidator returns true (SSO not required) _ssoRequestValidator.ValidateAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(Task.FromResult(true)); // No 2FA required @@ -1430,16 +1325,14 @@ public class BaseRequestValidatorTests /// (e.g., with organization identifier), that custom response is properly propagated to the result. /// [Theory] - [BitAutoData(true)] - [BitAutoData(false)] + [BitAutoData] public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_PropagatesCustomResponse( - bool recoveryCodeFeatureEnabled, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); _sut.isValid = true; @@ -1461,9 +1354,9 @@ public class BaseRequestValidatorTests var context = CreateContext(tokenRequest, requestContext, grantResult); _ssoRequestValidator.ValidateAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(Task.FromResult(false)); // Act @@ -1473,7 +1366,8 @@ public class BaseRequestValidatorTests 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"]); + Assert.Equal("test-org-identifier", + context.CustomValidatorRequestContext.CustomResponse["SsoOrganizationIdentifier"]); } /// @@ -1484,11 +1378,11 @@ public class BaseRequestValidatorTests [BitAutoData] public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_RecoveryWithSso_LegacyMessage( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true); _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -1509,7 +1403,7 @@ public class BaseRequestValidatorTests // SSO is required (legacy check) _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) .Returns(Task.FromResult(true)); // Act @@ -1535,11 +1429,11 @@ public class BaseRequestValidatorTests [BitAutoData] public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_RecoveryWithSso_NewValidatorMessage( [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] + CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange - SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true); _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -1568,13 +1462,16 @@ public class BaseRequestValidatorTests }; requestContext.CustomResponse = new Dictionary { - { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } + { + "ErrorModel", + new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") + } }; _ssoRequestValidator.ValidateAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(Task.FromResult(false)); // Act From 196e555116aba4666ae8c9cc9080291c42c1fe46 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:17:43 -0500 Subject: [PATCH 05/47] Refactor event integration service collection extensions into their own extension (#6714) * Add CQRS and caching support for OrganizationIntegrationConfigurations * Refactor event integration service collection extensions into their own extension --- ...IntegrationsServiceCollectionExtensions.cs | 505 ++++++++++++- src/Core/Core.csproj | 1 + src/Core/Utilities/CACHING.md | 2 +- src/Events/Startup.cs | 2 + src/EventsProcessor/Startup.cs | 3 +- .../Utilities/ServiceCollectionExtensions.cs | 297 -------- ...grationServiceCollectionExtensionsTests.cs | 667 ++++++++++++++++++ .../TestListenerConfiguration.cs | 1 + 8 files changed, 1178 insertions(+), 300 deletions(-) diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index 6b848be11f..bd8ebf6483 100644 --- a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -1,11 +1,24 @@ -using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Models.Teams; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.Services.NoopImplementations; +using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; +using TableStorageRepos = Bit.Core.Repositories.TableStorage; namespace Microsoft.Extensions.DependencyInjection; @@ -33,6 +46,460 @@ public static class EventIntegrationsServiceCollectionExtensions return services; } + /// + /// Registers event write services based on available configuration. + /// + /// The service collection to add services to. + /// The global settings containing event logging configuration. + /// The service collection for chaining. + /// + /// + /// This method registers the appropriate IEventWriteService implementation based on the available + /// configuration, checking in the following priority order: + /// + /// + /// 1. Azure Service Bus - If all Azure Service Bus settings are present, registers + /// EventIntegrationEventWriteService with AzureServiceBusService as the publisher + /// + /// + /// 2. RabbitMQ - If all RabbitMQ settings are present, registers EventIntegrationEventWriteService with + /// RabbitMqService as the publisher + /// + /// + /// 3. Azure Queue Storage - If Events.ConnectionString is present, registers AzureQueueEventWriteService + /// + /// + /// 4. Repository (Self-Hosted) - If SelfHosted is true, registers RepositoryEventWriteService + /// + /// + /// 5. Noop - If none of the above are configured, registers NoopEventWriteService (no-op implementation) + /// + /// + public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings) + { + if (IsAzureServiceBusEnabled(globalSettings)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) + { + services.TryAddSingleton(); + return services; + } + + if (globalSettings.SelfHosted) + { + services.TryAddSingleton(); + return services; + } + + services.TryAddSingleton(); + return services; + } + + /// + /// Registers Azure Service Bus-based event integration listeners and supporting infrastructure. + /// + /// The service collection to add services to. + /// The global settings containing Azure Service Bus configuration. + /// The service collection for chaining. + /// + /// + /// If Azure Service Bus is not enabled (missing required settings), this method returns immediately + /// without registering any services. + /// + /// + /// When Azure Service Bus is enabled, this method registers: + /// - IAzureServiceBusService and IEventIntegrationPublisher implementations + /// - Table Storage event repository + /// - Azure Table Storage event handler + /// - All event integration services via AddEventIntegrationServices + /// + /// + /// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method, + /// as it is required to create the event integrations extended cache. + /// + /// + public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings) + { + if (!IsAzureServiceBusEnabled(globalSettings)) + { + return services; + } + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("persistent"); + services.TryAddSingleton(); + + services.AddEventIntegrationServices(globalSettings); + + return services; + } + + /// + /// Registers RabbitMQ-based event integration listeners and supporting infrastructure. + /// + /// The service collection to add services to. + /// The global settings containing RabbitMQ configuration. + /// The service collection for chaining. + /// + /// + /// If RabbitMQ is not enabled (missing required settings), this method returns immediately + /// without registering any services. + /// + /// + /// When RabbitMQ is enabled, this method registers: + /// - IRabbitMqService and IEventIntegrationPublisher implementations + /// - Event repository handler + /// - All event integration services via AddEventIntegrationServices + /// + /// + /// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method, + /// as it is required to create the event integrations extended cache. + /// + /// + public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings) + { + if (!IsRabbitMqEnabled(globalSettings)) + { + return services; + } + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.AddEventIntegrationServices(globalSettings); + + return services; + } + + /// + /// Registers Slack integration services based on configuration settings. + /// + /// The service collection to add services to. + /// The global settings containing Slack configuration. + /// The service collection for chaining. + /// + /// If all required Slack settings are configured (ClientId, ClientSecret, Scopes), registers the full SlackService, + /// including an HttpClient for Slack API calls. Otherwise, registers a NoopSlackService that performs no operations. + /// + public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings) + { + if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) + { + services.AddHttpClient(SlackService.HttpClientName); + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + } + + return services; + } + + /// + /// Registers Microsoft Teams integration services based on configuration settings. + /// + /// The service collection to add services to. + /// The global settings containing Teams configuration. + /// The service collection for chaining. + /// + /// If all required Teams settings are configured (ClientId, ClientSecret, Scopes), registers: + /// - TeamsService and its interfaces (IBot, ITeamsService) + /// - IBotFrameworkHttpAdapter with Teams credentials + /// - HttpClient for Teams API calls + /// Otherwise, registers a NoopTeamsService that performs no operations. + /// + public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings) + { + if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes)) + { + services.AddHttpClient(TeamsService.HttpClientName); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(_ => + new BotFrameworkHttpAdapter( + new TeamsBotCredentialProvider( + clientId: globalSettings.Teams.ClientId, + clientSecret: globalSettings.Teams.ClientSecret + ) + ) + ); + } + else + { + services.TryAddSingleton(); + } + + return services; + } + + /// + /// Registers event integration services including handlers, listeners, and supporting infrastructure. + /// + /// The service collection to add services to. + /// The global settings containing integration configuration. + /// The service collection for chaining. + /// + /// + /// This method orchestrates the registration of all event integration components based on the enabled + /// message broker (Azure Service Bus or RabbitMQ). It is an internal method called by the public + /// entry points AddAzureServiceBusListeners and AddRabbitMqListeners. + /// + /// + /// NOTE: If both Azure Service Bus and RabbitMQ are configured, Azure Service Bus takes precedence. This means that + /// Azure Service Bus listeners will be registered (and RabbitMQ listeners will NOT) even if this event is called + /// from AddRabbitMqListeners when Azure Service Bus settings are configured. + /// + /// + /// PREREQUISITE: Callers must ensure AddDistributedCache has been called before invoking this method. + /// This method depends on distributed cache infrastructure being available for the keyed extended + /// cache registration. + /// + /// + /// Registered Services: + /// - Keyed ExtendedCache for event integrations + /// - Integration filter service + /// - Integration handlers for Slack, Webhook, Hec, Datadog, and Teams + /// - Hosted services for event and integration listeners (based on enabled message broker) + /// + /// + internal static IServiceCollection AddEventIntegrationServices(this IServiceCollection services, + GlobalSettings globalSettings) + { + // Add common services + // NOTE: AddDistributedCache must be called by the caller before this method + services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("persistent"); + + // Add services in support of handlers + services.AddSlackService(globalSettings); + services.AddTeamsService(globalSettings); + services.TryAddSingleton(TimeProvider.System); + services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); + services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); + + // Add integration handlers + services.TryAddSingleton, SlackIntegrationHandler>(); + services.TryAddSingleton, WebhookIntegrationHandler>(); + services.TryAddSingleton, DatadogIntegrationHandler>(); + services.TryAddSingleton, TeamsIntegrationHandler>(); + + var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); + var slackConfiguration = new SlackListenerConfiguration(globalSettings); + var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); + var hecConfiguration = new HecListenerConfiguration(globalSettings); + var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); + var teamsConfiguration = new TeamsListenerConfiguration(globalSettings); + + if (IsAzureServiceBusEnabled(globalSettings)) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusEventListenerService( + configuration: repositoryConfiguration, + handler: provider.GetRequiredService(), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = repositoryConfiguration.EventPrefetchCount, + MaxConcurrentCalls = repositoryConfiguration.EventMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.AddAzureServiceBusIntegration(slackConfiguration); + services.AddAzureServiceBusIntegration(webhookConfiguration); + services.AddAzureServiceBusIntegration(hecConfiguration); + services.AddAzureServiceBusIntegration(datadogConfiguration); + services.AddAzureServiceBusIntegration(teamsConfiguration); + + return services; + } + + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqEventListenerService( + handler: provider.GetRequiredService(), + configuration: repositoryConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.AddRabbitMqIntegration(slackConfiguration); + services.AddRabbitMqIntegration(webhookConfiguration); + services.AddRabbitMqIntegration(hecConfiguration); + services.AddRabbitMqIntegration(datadogConfiguration); + services.AddRabbitMqIntegration(teamsConfiguration); + } + + return services; + } + + /// + /// Registers Azure Service Bus-based event integration listeners for a specific integration type. + /// + /// The integration configuration details type (e.g., SlackIntegrationConfigurationDetails). + /// The listener configuration type implementing IIntegrationListenerConfiguration. + /// The service collection to add services to. + /// The listener configuration containing routing keys and message processing settings. + /// The service collection for chaining. + /// + /// + /// This method registers three key components: + /// 1. EventIntegrationHandler - Keyed singleton for processing integration events + /// 2. AzureServiceBusEventListenerService - Hosted service for listening to event messages from Azure Service Bus + /// for this integration type + /// 3. AzureServiceBusIntegrationListenerService - Hosted service for listening to integration messages from + /// Azure Service Bus for this integration type + /// + /// + /// The handler uses the listener configuration's routing key as its service key, allowing multiple + /// handlers to be registered for different integration types. + /// + /// + /// Service Bus processor options (PrefetchCount and MaxConcurrentCalls) are configured from the listener + /// configuration to optimize message throughput and concurrency. + /// + /// + internal static IServiceCollection AddAzureServiceBusIntegration(this IServiceCollection services, + TListenerConfig listenerConfiguration) + where TConfig : class + where TListenerConfig : IIntegrationListenerConfiguration + { + services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => + new EventIntegrationHandler( + integrationType: listenerConfiguration.IntegrationType, + eventIntegrationPublisher: provider.GetRequiredService(), + integrationFilterService: provider.GetRequiredService(), + cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), + configurationRepository: provider.GetRequiredService(), + groupRepository: provider.GetRequiredService(), + organizationRepository: provider.GetRequiredService(), + organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusEventListenerService( + configuration: listenerConfiguration, + handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = listenerConfiguration.EventPrefetchCount, + MaxConcurrentCalls = listenerConfiguration.EventMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusIntegrationListenerService( + configuration: listenerConfiguration, + handler: provider.GetRequiredService>(), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = listenerConfiguration.IntegrationPrefetchCount, + MaxConcurrentCalls = listenerConfiguration.IntegrationMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + + return services; + } + + /// + /// Registers RabbitMQ-based event integration listeners for a specific integration type. + /// + /// The integration configuration details type (e.g., SlackIntegrationConfigurationDetails). + /// The listener configuration type implementing IIntegrationListenerConfiguration. + /// The service collection to add services to. + /// The listener configuration containing routing keys and message processing settings. + /// The service collection for chaining. + /// + /// + /// This method registers three key components: + /// 1. EventIntegrationHandler - Keyed singleton for processing integration events + /// 2. RabbitMqEventListenerService - Hosted service for listening to event messages from RabbitMQ for + /// this integration type + /// 3. RabbitMqIntegrationListenerService - Hosted service for listening to integration messages from RabbitMQ for + /// this integration type + /// + /// + /// + /// The handler uses the listener configuration's routing key as its service key, allowing multiple + /// handlers to be registered for different integration types. + /// + /// + internal static IServiceCollection AddRabbitMqIntegration(this IServiceCollection services, + TListenerConfig listenerConfiguration) + where TConfig : class + where TListenerConfig : IIntegrationListenerConfiguration + { + services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => + new EventIntegrationHandler( + integrationType: listenerConfiguration.IntegrationType, + eventIntegrationPublisher: provider.GetRequiredService(), + integrationFilterService: provider.GetRequiredService(), + cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), + configurationRepository: provider.GetRequiredService(), + groupRepository: provider.GetRequiredService(), + organizationRepository: provider.GetRequiredService(), + organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqEventListenerService( + handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), + configuration: listenerConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqIntegrationListenerService( + handler: provider.GetRequiredService>(), + configuration: listenerConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService(), + timeProvider: provider.GetRequiredService() + ) + ) + ); + + return services; + } + internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services) { services.TryAddScoped(); @@ -52,4 +519,40 @@ public static class EventIntegrationsServiceCollectionExtensions return services; } + + /// + /// Determines if RabbitMQ is enabled for event integrations based on configuration settings. + /// + /// The global settings containing RabbitMQ configuration. + /// True if all required RabbitMQ settings are present; otherwise, false. + /// + /// Requires all the following settings to be configured: + /// - EventLogging.RabbitMq.HostName + /// - EventLogging.RabbitMq.Username + /// - EventLogging.RabbitMq.Password + /// - EventLogging.RabbitMq.EventExchangeName + /// + internal static bool IsRabbitMqEnabled(GlobalSettings settings) + { + return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName); + } + + /// + /// Determines if Azure Service Bus is enabled for event integrations based on configuration settings. + /// + /// The global settings containing Azure Service Bus configuration. + /// True if all required Azure Service Bus settings are present; otherwise, false. + /// + /// Requires both of the following settings to be configured: + /// - EventLogging.AzureServiceBus.ConnectionString + /// - EventLogging.AzureServiceBus.EventTopicName + /// + internal static bool IsAzureServiceBusEnabled(GlobalSettings settings) + { + return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) && + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName); + } } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 4902d5bdbe..52c0a641ab 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -41,6 +41,7 @@ + diff --git a/src/Core/Utilities/CACHING.md b/src/Core/Utilities/CACHING.md index d80e629bdd..c29a14d751 100644 --- a/src/Core/Utilities/CACHING.md +++ b/src/Core/Utilities/CACHING.md @@ -381,7 +381,7 @@ public class OrganizationAbilityService ### Example Usage: Default (Ephemeral Data) -#### 1. Registration (already done in Api, Admin, Billing, Identity, and Notifications Startup.cs files, plus Events and EventsProcessor service collection extensions): +#### 1. Registration (already done in Api, Admin, Billing, Events, EventsProcessor, Identity, and Notifications Startup.cs files): ```csharp services.AddDistributedCache(globalSettings); diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index f67debd092..75301cf08c 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -84,6 +84,8 @@ public class Startup services.AddHostedService(); } + // Add event integration services + services.AddDistributedCache(globalSettings); services.AddRabbitMqListeners(globalSettings); } diff --git a/src/EventsProcessor/Startup.cs b/src/EventsProcessor/Startup.cs index 260c501e01..888dda43a1 100644 --- a/src/EventsProcessor/Startup.cs +++ b/src/EventsProcessor/Startup.cs @@ -31,7 +31,8 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); - // Hosted Services + // Add event integration services + services.AddDistributedCache(globalSettings); services.AddAzureServiceBusListeners(globalSettings); services.AddHostedService(); } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index d1fb0b8ac4..8dd629e63a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,14 +6,10 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; -using Azure.Messaging.ServiceBus; using Bit.Core; using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -using Bit.Core.AdminConsole.Models.Teams; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.NoopImplementations; @@ -74,8 +70,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Azure.Cosmos.Fluent; -using Microsoft.Bot.Builder; -using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Extensions.Caching.Cosmos; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; @@ -87,7 +81,6 @@ 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; using TableStorageRepos = Bit.Core.Repositories.TableStorage; @@ -525,116 +518,6 @@ public static class ServiceCollectionExtensions return globalSettings; } - public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings) - { - if (IsAzureServiceBusEnabled(globalSettings)) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - return services; - } - - if (IsRabbitMqEnabled(globalSettings)) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - return services; - } - - if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) - { - services.TryAddSingleton(); - return services; - } - - if (globalSettings.SelfHosted) - { - services.TryAddSingleton(); - return services; - } - - services.TryAddSingleton(); - return services; - } - - public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings) - { - if (!IsAzureServiceBusEnabled(globalSettings)) - { - return services; - } - - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddKeyedSingleton("persistent"); - services.TryAddSingleton(); - - services.AddEventIntegrationServices(globalSettings); - - return services; - } - - public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings) - { - if (!IsRabbitMqEnabled(globalSettings)) - { - return services; - } - - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - - services.AddEventIntegrationServices(globalSettings); - - return services; - } - - public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings) - { - if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && - CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) && - CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) - { - services.AddHttpClient(SlackService.HttpClientName); - services.TryAddSingleton(); - } - else - { - services.TryAddSingleton(); - } - - return services; - } - - public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings) - { - if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) && - CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) && - CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes)) - { - services.AddHttpClient(TeamsService.HttpClientName); - services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(sp => - new BotFrameworkHttpAdapter( - new TeamsBotCredentialProvider( - clientId: globalSettings.Teams.ClientId, - clientSecret: globalSettings.Teams.ClientSecret - ) - ) - ); - } - else - { - services.TryAddSingleton(); - } - - return services; - } - public static void UseDefaultMiddleware(this IApplicationBuilder app, IWebHostEnvironment env, GlobalSettings globalSettings) { @@ -881,186 +764,6 @@ public static class ServiceCollectionExtensions return (provider, connectionString); } - private static IServiceCollection AddAzureServiceBusIntegration(this IServiceCollection services, - TListenerConfig listenerConfiguration) - where TConfig : class - where TListenerConfig : IIntegrationListenerConfiguration - { - services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => - new EventIntegrationHandler( - integrationType: listenerConfiguration.IntegrationType, - eventIntegrationPublisher: provider.GetRequiredService(), - integrationFilterService: provider.GetRequiredService(), - cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), - configurationRepository: provider.GetRequiredService(), - groupRepository: provider.GetRequiredService(), - organizationRepository: provider.GetRequiredService(), - organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) - ); - services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => - new AzureServiceBusEventListenerService( - configuration: listenerConfiguration, - handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), - serviceBusService: provider.GetRequiredService(), - serviceBusOptions: new ServiceBusProcessorOptions() - { - PrefetchCount = listenerConfiguration.EventPrefetchCount, - MaxConcurrentCalls = listenerConfiguration.EventMaxConcurrentCalls - }, - loggerFactory: provider.GetRequiredService() - ) - ) - ); - services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => - new AzureServiceBusIntegrationListenerService( - configuration: listenerConfiguration, - handler: provider.GetRequiredService>(), - serviceBusService: provider.GetRequiredService(), - serviceBusOptions: new ServiceBusProcessorOptions() - { - PrefetchCount = listenerConfiguration.IntegrationPrefetchCount, - MaxConcurrentCalls = listenerConfiguration.IntegrationMaxConcurrentCalls - }, - loggerFactory: provider.GetRequiredService() - ) - ) - ); - - return services; - } - - private static IServiceCollection AddEventIntegrationServices(this IServiceCollection services, - GlobalSettings globalSettings) - { - // Add common services - services.AddDistributedCache(globalSettings); - services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); - services.TryAddSingleton(); - services.TryAddKeyedSingleton("persistent"); - - // Add services in support of handlers - services.AddSlackService(globalSettings); - services.AddTeamsService(globalSettings); - services.TryAddSingleton(TimeProvider.System); - services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); - services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); - - // Add integration handlers - services.TryAddSingleton, SlackIntegrationHandler>(); - services.TryAddSingleton, WebhookIntegrationHandler>(); - services.TryAddSingleton, DatadogIntegrationHandler>(); - services.TryAddSingleton, TeamsIntegrationHandler>(); - - var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); - var slackConfiguration = new SlackListenerConfiguration(globalSettings); - var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); - var hecConfiguration = new HecListenerConfiguration(globalSettings); - var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); - var teamsConfiguration = new TeamsListenerConfiguration(globalSettings); - - if (IsRabbitMqEnabled(globalSettings)) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => - new RabbitMqEventListenerService( - handler: provider.GetRequiredService(), - configuration: repositoryConfiguration, - rabbitMqService: provider.GetRequiredService(), - loggerFactory: provider.GetRequiredService() - ) - ) - ); - services.AddRabbitMqIntegration(slackConfiguration); - services.AddRabbitMqIntegration(webhookConfiguration); - services.AddRabbitMqIntegration(hecConfiguration); - services.AddRabbitMqIntegration(datadogConfiguration); - services.AddRabbitMqIntegration(teamsConfiguration); - } - - if (IsAzureServiceBusEnabled(globalSettings)) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => - new AzureServiceBusEventListenerService( - configuration: repositoryConfiguration, - handler: provider.GetRequiredService(), - serviceBusService: provider.GetRequiredService(), - serviceBusOptions: new ServiceBusProcessorOptions() - { - PrefetchCount = repositoryConfiguration.EventPrefetchCount, - MaxConcurrentCalls = repositoryConfiguration.EventMaxConcurrentCalls - }, - loggerFactory: provider.GetRequiredService() - ) - ) - ); - services.AddAzureServiceBusIntegration(slackConfiguration); - services.AddAzureServiceBusIntegration(webhookConfiguration); - services.AddAzureServiceBusIntegration(hecConfiguration); - services.AddAzureServiceBusIntegration(datadogConfiguration); - services.AddAzureServiceBusIntegration(teamsConfiguration); - } - - return services; - } - - private static IServiceCollection AddRabbitMqIntegration(this IServiceCollection services, - TListenerConfig listenerConfiguration) - where TConfig : class - where TListenerConfig : IIntegrationListenerConfiguration - { - services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => - new EventIntegrationHandler( - integrationType: listenerConfiguration.IntegrationType, - eventIntegrationPublisher: provider.GetRequiredService(), - integrationFilterService: provider.GetRequiredService(), - cache: provider.GetRequiredKeyedService(EventIntegrationsCacheConstants.CacheName), - configurationRepository: provider.GetRequiredService(), - groupRepository: provider.GetRequiredService(), - organizationRepository: provider.GetRequiredService(), - organizationUserRepository: provider.GetRequiredService(), logger: provider.GetRequiredService>>()) - ); - services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => - new RabbitMqEventListenerService( - handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), - configuration: listenerConfiguration, - rabbitMqService: provider.GetRequiredService(), - loggerFactory: provider.GetRequiredService() - ) - ) - ); - services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => - new RabbitMqIntegrationListenerService( - handler: provider.GetRequiredService>(), - configuration: listenerConfiguration, - rabbitMqService: provider.GetRequiredService(), - loggerFactory: provider.GetRequiredService(), - timeProvider: provider.GetRequiredService() - ) - ) - ); - - return services; - } - - private static bool IsAzureServiceBusEnabled(GlobalSettings settings) - { - return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) && - CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName); - } - - private static bool IsRabbitMqEnabled(GlobalSettings settings) - { - return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && - CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && - 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. /// diff --git a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs index 4711426677..aca783ade2 100644 --- a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -1,12 +1,19 @@ using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.Services.NoopImplementations; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using NSubstitute; using StackExchange.Redis; using Xunit; @@ -185,6 +192,666 @@ public class EventIntegrationServiceCollectionExtensionsTests Assert.Single(createCmdDescriptors); } + [Fact] + public void IsRabbitMqEnabled_AllSettingsPresent_ReturnsTrue() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + Assert.True(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsRabbitMqEnabled_MissingHostName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = null, + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsRabbitMqEnabled_MissingUsername_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = null, + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsRabbitMqEnabled_MissingPassword_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = null, + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsRabbitMqEnabled_MissingExchangeName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = null + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsAzureServiceBusEnabled_AllSettingsPresent_ReturnsTrue() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + }); + + Assert.True(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); + } + + [Fact] + public void IsAzureServiceBusEnabled_MissingConnectionString_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = null, + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); + } + + [Fact] + public void IsAzureServiceBusEnabled_MissingTopicName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = null + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); + } + + [Fact] + public void AddSlackService_AllSettingsPresent_RegistersSlackService() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:Slack:ClientId"] = "test-client-id", + ["GlobalSettings:Slack:ClientSecret"] = "test-client-secret", + ["GlobalSettings:Slack:Scopes"] = "test-scopes" + }); + + services.TryAddSingleton(globalSettings); + services.AddLogging(); + services.AddSlackService(globalSettings); + + var provider = services.BuildServiceProvider(); + var slackService = provider.GetService(); + + Assert.NotNull(slackService); + Assert.IsType(slackService); + + var httpClientDescriptor = services.FirstOrDefault(s => + s.ServiceType == typeof(IHttpClientFactory)); + Assert.NotNull(httpClientDescriptor); + } + + [Fact] + public void AddSlackService_SettingsMissing_RegistersNoopService() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:Slack:ClientId"] = null, + ["GlobalSettings:Slack:ClientSecret"] = null, + ["GlobalSettings:Slack:Scopes"] = null + }); + + services.AddSlackService(globalSettings); + + var provider = services.BuildServiceProvider(); + var slackService = provider.GetService(); + + Assert.NotNull(slackService); + Assert.IsType(slackService); + } + + [Fact] + public void AddTeamsService_AllSettingsPresent_RegistersTeamsServices() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:Teams:ClientId"] = "test-client-id", + ["GlobalSettings:Teams:ClientSecret"] = "test-client-secret", + ["GlobalSettings:Teams:Scopes"] = "test-scopes" + }); + + services.TryAddSingleton(globalSettings); + services.AddLogging(); + services.TryAddScoped(_ => Substitute.For()); + services.AddTeamsService(globalSettings); + + var provider = services.BuildServiceProvider(); + + var teamsService = provider.GetService(); + Assert.NotNull(teamsService); + Assert.IsType(teamsService); + + var bot = provider.GetService(); + Assert.NotNull(bot); + Assert.IsType(bot); + + var adapter = provider.GetService(); + Assert.NotNull(adapter); + Assert.IsType(adapter); + + var httpClientDescriptor = services.FirstOrDefault(s => + s.ServiceType == typeof(IHttpClientFactory)); + Assert.NotNull(httpClientDescriptor); + } + + [Fact] + public void AddTeamsService_SettingsMissing_RegistersNoopService() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:Teams:ClientId"] = null, + ["GlobalSettings:Teams:ClientSecret"] = null, + ["GlobalSettings:Teams:Scopes"] = null + }); + + services.AddTeamsService(globalSettings); + + var provider = services.BuildServiceProvider(); + var teamsService = provider.GetService(); + + Assert.NotNull(teamsService); + Assert.IsType(teamsService); + } + + [Fact] + public void AddRabbitMqIntegration_RegistersEventIntegrationHandler() + { + var services = new ServiceCollection(); + var listenerConfig = new TestListenerConfiguration(); + + // Add required dependencies + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddKeyedSingleton(EventIntegrationsCacheConstants.CacheName, Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddRabbitMqIntegration(listenerConfig); + + var provider = services.BuildServiceProvider(); + var handler = provider.GetRequiredKeyedService(listenerConfig.RoutingKey); + + Assert.NotNull(handler); + } + + [Fact] + public void AddRabbitMqIntegration_RegistersEventListenerService() + { + var services = new ServiceCollection(); + var listenerConfig = new TestListenerConfiguration(); + + // Add required dependencies + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddKeyedSingleton(EventIntegrationsCacheConstants.CacheName, Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddRabbitMqIntegration(listenerConfig); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // AddRabbitMqIntegration should register 2 hosted services (Event + Integration listeners) + Assert.Equal(2, afterCount - beforeCount); + } + + [Fact] + public void AddRabbitMqIntegration_RegistersIntegrationListenerService() + { + var services = new ServiceCollection(); + var listenerConfig = new TestListenerConfiguration(); + + // Add required dependencies + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddKeyedSingleton(EventIntegrationsCacheConstants.CacheName, Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For>()); + services.TryAddSingleton(TimeProvider.System); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddRabbitMqIntegration(listenerConfig); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // AddRabbitMqIntegration should register 2 hosted services (Event + Integration listeners) + Assert.Equal(2, afterCount - beforeCount); + } + + [Fact] + public void AddAzureServiceBusIntegration_RegistersEventIntegrationHandler() + { + var services = new ServiceCollection(); + var listenerConfig = new TestListenerConfiguration(); + + // Add required dependencies + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddKeyedSingleton(EventIntegrationsCacheConstants.CacheName, Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddAzureServiceBusIntegration(listenerConfig); + + var provider = services.BuildServiceProvider(); + var handler = provider.GetRequiredKeyedService(listenerConfig.RoutingKey); + + Assert.NotNull(handler); + } + + [Fact] + public void AddAzureServiceBusIntegration_RegistersEventListenerService() + { + var services = new ServiceCollection(); + var listenerConfig = new TestListenerConfiguration(); + + // Add required dependencies + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddKeyedSingleton(EventIntegrationsCacheConstants.CacheName, Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddAzureServiceBusIntegration(listenerConfig); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // AddAzureServiceBusIntegration should register 2 hosted services (Event + Integration listeners) + Assert.Equal(2, afterCount - beforeCount); + } + + [Fact] + public void AddAzureServiceBusIntegration_RegistersIntegrationListenerService() + { + var services = new ServiceCollection(); + var listenerConfig = new TestListenerConfiguration(); + + // Add required dependencies + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddKeyedSingleton(EventIntegrationsCacheConstants.CacheName, Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For>()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddAzureServiceBusIntegration(listenerConfig); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // AddAzureServiceBusIntegration should register 2 hosted services (Event + Integration listeners) + Assert.Equal(2, afterCount - beforeCount); + } + + [Fact] + public void AddEventIntegrationServices_RegistersCommonServices() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings([]); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddEventIntegrationServices(globalSettings); + + // Verify common services are registered + Assert.Contains(services, s => s.ServiceType == typeof(IIntegrationFilterService)); + Assert.Contains(services, s => s.ServiceType == typeof(TimeProvider)); + + // Verify HttpClients for handlers are registered + var httpClientDescriptors = services.Where(s => s.ServiceType == typeof(IHttpClientFactory)).ToList(); + Assert.NotEmpty(httpClientDescriptors); + } + + [Fact] + public void AddEventIntegrationServices_RegistersIntegrationHandlers() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings([]); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddEventIntegrationServices(globalSettings); + + // Verify integration handlers are registered + Assert.Contains(services, s => s.ServiceType == typeof(IIntegrationHandler)); + Assert.Contains(services, s => s.ServiceType == typeof(IIntegrationHandler)); + Assert.Contains(services, s => s.ServiceType == typeof(IIntegrationHandler)); + Assert.Contains(services, s => s.ServiceType == typeof(IIntegrationHandler)); + } + + [Fact] + public void AddEventIntegrationServices_RabbitMqEnabled_RegistersRabbitMqListeners() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddEventIntegrationServices(globalSettings); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // Should register 11 hosted services for RabbitMQ: 1 repository + 5*2 integration listeners (event+integration) + Assert.Equal(11, afterCount - beforeCount); + } + + [Fact] + public void AddEventIntegrationServices_AzureServiceBusEnabled_RegistersAzureListeners() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + }); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddEventIntegrationServices(globalSettings); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // Should register 11 hosted services for Azure Service Bus: 1 repository + 5*2 integration listeners (event+integration) + Assert.Equal(11, afterCount - beforeCount); + } + + [Fact] + public void AddEventIntegrationServices_BothEnabled_AzureServiceBusTakesPrecedence() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + }); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddEventIntegrationServices(globalSettings); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // Should register 11 hosted services for Azure Service Bus: 1 repository + 5*2 integration listeners (event+integration) + // NO RabbitMQ services should be enabled because ASB takes precedence + Assert.Equal(11, afterCount - beforeCount); + } + + [Fact] + public void AddEventIntegrationServices_NeitherEnabled_RegistersNoListeners() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings([]); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + var beforeCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + services.AddEventIntegrationServices(globalSettings); + var afterCount = services.Count(s => s.ServiceType == typeof(IHostedService)); + + // Should register no hosted services when neither RabbitMQ nor Azure Service Bus is enabled + Assert.Equal(0, afterCount - beforeCount); + } + + [Fact] + public void AddEventWriteServices_AzureServiceBusEnabled_RegistersAzureServices() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + }); + + services.AddEventWriteServices(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IEventIntegrationPublisher) && s.ImplementationType == typeof(AzureServiceBusService)); + Assert.Contains(services, s => s.ServiceType == typeof(IEventWriteService) && s.ImplementationType == typeof(EventIntegrationEventWriteService)); + } + + [Fact] + public void AddEventWriteServices_RabbitMqEnabled_RegistersRabbitMqServices() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + services.AddEventWriteServices(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IEventIntegrationPublisher) && s.ImplementationType == typeof(RabbitMqService)); + Assert.Contains(services, s => s.ServiceType == typeof(IEventWriteService) && s.ImplementationType == typeof(EventIntegrationEventWriteService)); + } + + [Fact] + public void AddEventWriteServices_EventsConnectionStringPresent_RegistersAzureQueueService() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:Events:ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test;EndpointSuffix=core.windows.net" + }); + + services.AddEventWriteServices(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IEventWriteService) && s.ImplementationType == typeof(AzureQueueEventWriteService)); + } + + [Fact] + public void AddEventWriteServices_SelfHosted_RegistersRepositoryService() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:SelfHosted"] = "true" + }); + + services.AddEventWriteServices(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IEventWriteService) && s.ImplementationType == typeof(RepositoryEventWriteService)); + } + + [Fact] + public void AddEventWriteServices_NothingEnabled_RegistersNoopService() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings([]); + + services.AddEventWriteServices(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IEventWriteService) && s.ImplementationType == typeof(NoopEventWriteService)); + } + + [Fact] + public void AddEventWriteServices_AzureTakesPrecedenceOverRabbitMq() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + services.AddEventWriteServices(globalSettings); + + // Should use Azure Service Bus, not RabbitMQ + Assert.Contains(services, s => s.ServiceType == typeof(IEventIntegrationPublisher) && s.ImplementationType == typeof(AzureServiceBusService)); + Assert.DoesNotContain(services, s => s.ServiceType == typeof(IEventIntegrationPublisher) && s.ImplementationType == typeof(RabbitMqService)); + } + + [Fact] + public void AddAzureServiceBusListeners_AzureServiceBusEnabled_RegistersAzureServiceBusServices() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + }); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddAzureServiceBusListeners(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IAzureServiceBusService)); + Assert.Contains(services, s => s.ServiceType == typeof(IEventRepository)); + Assert.Contains(services, s => s.ServiceType == typeof(AzureTableStorageEventHandler)); + } + + [Fact] + public void AddAzureServiceBusListeners_AzureServiceBusDisabled_ReturnsEarly() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings([]); + + var initialCount = services.Count; + services.AddAzureServiceBusListeners(globalSettings); + var finalCount = services.Count; + + Assert.Equal(initialCount, finalCount); + } + + [Fact] + public void AddRabbitMqListeners_RabbitMqEnabled_RegistersRabbitMqServices() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + }); + + // Add prerequisites + services.TryAddSingleton(globalSettings); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + + services.AddRabbitMqListeners(globalSettings); + + Assert.Contains(services, s => s.ServiceType == typeof(IRabbitMqService)); + Assert.Contains(services, s => s.ServiceType == typeof(EventRepositoryHandler)); + } + + [Fact] + public void AddRabbitMqListeners_RabbitMqDisabled_ReturnsEarly() + { + var services = new ServiceCollection(); + var globalSettings = CreateGlobalSettings([]); + + var initialCount = services.Count; + services.AddRabbitMqListeners(globalSettings); + var finalCount = services.Count; + + Assert.Equal(initialCount, finalCount); + } + private static GlobalSettings CreateGlobalSettings(Dictionary data) { var config = new ConfigurationBuilder() diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/TestListenerConfiguration.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/TestListenerConfiguration.cs index 916fe981de..50442dd463 100644 --- a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/TestListenerConfiguration.cs +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/TestListenerConfiguration.cs @@ -17,4 +17,5 @@ public class TestListenerConfiguration : IIntegrationListenerConfiguration public int EventPrefetchCount => 0; public int IntegrationMaxConcurrentCalls => 1; public int IntegrationPrefetchCount => 0; + public string RoutingKey => IntegrationType.ToRoutingKey(); } From 99e132603926ccea532500553b62de09310bd4aa Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:32:43 -0600 Subject: [PATCH 06/47] [PM-24616] refactor stripe adapter (#6527) * move billing services+tests to billing namespaces * reorganized methods in file and added comment headers * renamed StripeAdapter methods for better clarity * clean up redundant qualifiers * Upgrade Stripe.net to v48.4.0 * Update PreviewTaxAmountCommand * Remove unused UpcomingInvoiceOptionExtensions * Added SubscriptionExtensions with GetCurrentPeriodEnd * Update PremiumUserBillingService * Update OrganizationBillingService * Update GetOrganizationWarningsQuery * Update BillingHistoryInfo * Update SubscriptionInfo * Remove unused Sql Billing folder * Update StripeAdapter * Update StripePaymentService * Update InvoiceCreatedHandler * Update PaymentFailedHandler * Update PaymentSucceededHandler * Update ProviderEventService * Update StripeEventUtilityService * Update SubscriptionDeletedHandler * Update SubscriptionUpdatedHandler * Update UpcomingInvoiceHandler * Update ProviderSubscriptionResponse * Remove unused Stripe Subscriptions Admin Tool * Update RemoveOrganizationFromProviderCommand * Update ProviderBillingService * Update RemoveOrganizatinoFromProviderCommandTests * Update PreviewTaxAmountCommandTests * Update GetCloudOrganizationLicenseQueryTests * Update GetOrganizationWarningsQueryTests * Update StripePaymentServiceTests * Update ProviderBillingControllerTests * Update ProviderEventServiceTests * Update SubscriptionDeletedHandlerTests * Update SubscriptionUpdatedHandlerTests * Resolve Billing test failures I completely removed tests for the StripeEventService as they were using a system I setup a while back that read JSON files of the Stripe event structure. I did not anticipate how frequently these structures would change with each API version and the cost of trying to update these specific JSON files to test a very static data retrieval service far outweigh the benefit. * Resolve Core test failures * Run dotnet format * Remove unused provider migration * Fixed failing tests * Run dotnet format * Replace the old webhook secret key with new one (#6223) * Fix compilation failures in additions * Run dotnet format * Bump Stripe API version * Fix recent addition: CreatePremiumCloudHostedSubscriptionCommand * Fix new code in main according to Stripe update * Fix InvoiceExtensions * Bump SDK version to match API Version * cleanup * fixing items missed after the merge * use expression body for all simple returns * forgot fixes, format, and pr feedback * claude pr feedback * pr feedback and cleanup * more claude feedback --------- Co-authored-by: Alex Morask Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> --- .gitignore | 1 + .../RemoveOrganizationFromProviderCommand.cs | 13 +- .../AdminConsole/Services/ProviderService.cs | 7 +- .../Queries/GetProviderWarningsQuery.cs | 5 +- .../Services/BusinessUnitConverter.cs | 6 +- .../Services/ProviderBillingService.cs | 28 +- .../src/Scim/Users/PostUserCommand.cs | 3 +- ...oveOrganizationFromProviderCommandTests.cs | 28 +- .../Services/ProviderServiceTests.cs | 7 +- .../Queries/GetProviderWarningsQueryTests.cs | 29 +- .../Services/BusinessUnitConverterTests.cs | 4 +- .../Services/ProviderBillingServiceTests.cs | 75 +++-- .../Scim.Test/Users/PostUserCommandTests.cs | 3 +- .../Controllers/OrganizationsController.cs | 5 +- .../Controllers/ProvidersController.cs | 4 +- src/Admin/Controllers/ToolsController.cs | 1 + src/Admin/Controllers/UsersController.cs | 5 +- .../Public/Controllers/MembersController.cs | 5 +- .../Controllers/AccountsBillingController.cs | 2 +- .../Billing/Controllers/AccountsController.cs | 2 +- .../OrganizationBillingController.cs | 3 +- .../Controllers/OrganizationsController.cs | 2 +- .../Controllers/ProviderBillingController.cs | 6 +- .../Billing/Controllers/StripeController.cs | 8 +- src/Billing/Controllers/BitPayController.cs | 2 +- src/Billing/Controllers/PayPalController.cs | 4 +- .../SetupIntentSucceededHandler.cs | 6 +- ...ImportOrganizationUsersAndGroupsCommand.cs | 5 +- .../InviteOrganizationUserValidator.cs | 4 +- .../InviteUsersPasswordManagerValidator.cs | 4 +- .../CloudOrganizationSignUpCommand.cs | 3 +- .../OrganizationDeleteCommand.cs | 5 +- ...ResellerClientOrganizationSignUpCommand.cs | 5 +- .../SelfHostedOrganizationSignUpCommand.cs | 4 +- .../UpdateOrganizationSubscriptionCommand.cs | 4 +- .../Implementations/OrganizationService.cs | 7 +- .../Commands/PreviewOrganizationTaxCommand.cs | 14 +- .../GetCloudOrganizationLicenseQuery.cs | 4 +- .../Queries/GetOrganizationWarningsQuery.cs | 7 +- .../Services/OrganizationBillingService.cs | 17 +- .../Commands/UpdateBillingAddressCommand.cs | 13 +- .../Commands/UpdatePaymentMethodCommand.cs | 13 +- .../Payment/Queries/GetPaymentMethodQuery.cs | 3 +- .../Payment/Queries/HasPaymentMethodQuery.cs | 3 +- ...tePremiumCloudHostedSubscriptionCommand.cs | 10 +- .../Commands/PreviewPremiumTaxCommand.cs | 4 +- src/Core/Billing/Services/IStripeAdapter.cs | 50 +++ .../Services/IStripePaymentService.cs} | 4 +- .../Billing/Services/IStripeSyncService.cs | 6 + .../Implementations/PaymentHistoryService.cs | 3 +- .../PremiumUserBillingService.cs | 15 +- .../Services/Implementations/StripeAdapter.cs | 209 +++++++++++++ .../Implementations/StripePaymentService.cs | 99 +++--- .../Implementations/StripeSyncService.cs | 8 +- .../Implementations/SubscriberService.cs | 65 ++-- .../Commands/RestartSubscriptionCommand.cs | 3 +- src/Core/Billing/Utilities.cs | 4 +- .../Cloud/SetUpSponsorshipCommand.cs | 6 +- .../Cloud/ValidateSponsorshipCommand.cs | 5 +- .../AddSecretsManagerSubscriptionCommand.cs | 5 +- ...UpdateSecretsManagerSubscriptionCommand.cs | 5 +- .../UpgradeOrganizationPlanCommand.cs | 5 +- src/Core/Services/IStripeAdapter.cs | 54 ---- src/Core/Services/IStripeSyncService.cs | 6 - .../Services/Implementations/StripeAdapter.cs | 284 ------------------ .../Services/Implementations/UserService.cs | 8 +- src/Core/Utilities/BillingHelpers.cs | 6 +- .../Utilities/ServiceCollectionExtensions.cs | 2 +- .../Controllers/AccountsControllerTests.cs | 4 +- .../OrganizationBillingControllerTests.cs | 4 +- .../OrganizationsControllerTests.cs | 4 +- .../ProviderBillingControllerTests.cs | 16 +- .../Controllers/BitPayControllerTests.cs | 2 +- .../Controllers/PayPalControllerTests.cs | 2 +- .../SetupIntentSucceededHandlerTests.cs | 14 +- ...tOrganizationUsersAndGroupsCommandTests.cs | 3 +- .../InviteOrganizationUsersValidatorTests.cs | 8 +- ...lerClientOrganizationSignUpCommandTests.cs | 3 +- ...ateOrganizationSubscriptionCommandTests.cs | 12 +- .../Services/OrganizationServiceTests.cs | 5 +- .../PreviewOrganizationTaxCommandTests.cs | 112 +++---- .../GetCloudOrganizationLicenseQueryTests.cs | 7 +- .../GetOrganizationWarningsQueryTests.cs | 15 +- .../UpdateBillingAddressCommandTests.cs | 31 +- .../UpdatePaymentMethodCommandTests.cs | 21 +- .../Queries/GetPaymentMethodQueryTests.cs | 3 +- .../Queries/HasPaymentMethodQueryTests.cs | 7 +- ...miumCloudHostedSubscriptionCommandTests.cs | 108 +++---- .../Commands/PreviewPremiumTaxCommandTests.cs | 32 +- .../OrganizationBillingServiceTests.cs | 23 +- .../Services/PaymentHistoryServiceTests.cs | 6 +- .../Services/StripePaymentServiceTests.cs | 23 +- .../Services/SubscriberServiceTests.cs | 200 ++++++------ .../RestartSubscriptionCommandTests.cs | 13 +- .../CancelSponsorshipCommandTestsBase.cs | 5 +- .../Cloud/SetUpSponsorshipCommandTests.cs | 4 +- ...dSecretsManagerSubscriptionCommandTests.cs | 5 +- ...eSecretsManagerSubscriptionCommandTests.cs | 15 +- .../UpgradeOrganizationPlanCommandTests.cs | 3 +- .../Factories/WebApplicationFactoryBase.cs | 2 +- 100 files changed, 941 insertions(+), 1016 deletions(-) create mode 100644 src/Core/Billing/Services/IStripeAdapter.cs rename src/Core/{Services/IPaymentService.cs => Billing/Services/IStripePaymentService.cs} (97%) create mode 100644 src/Core/Billing/Services/IStripeSyncService.cs create mode 100644 src/Core/Billing/Services/Implementations/StripeAdapter.cs rename src/Core/{ => Billing}/Services/Implementations/StripePaymentService.cs (90%) rename src/Core/{ => Billing}/Services/Implementations/StripeSyncService.cs (68%) delete mode 100644 src/Core/Services/IStripeAdapter.cs delete mode 100644 src/Core/Services/IStripeSyncService.cs delete mode 100644 src/Core/Services/Implementations/StripeAdapter.cs rename test/Core.Test/{ => Billing}/Services/StripePaymentServiceTests.cs (96%) diff --git a/.gitignore b/.gitignore index 60fc894285..db8cb50f84 100644 --- a/.gitignore +++ b/.gitignore @@ -234,6 +234,7 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json +.serena/ # Serena .serena/ diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 994b305349..12d370395c 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -113,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv await _providerBillingService.CreateCustomerForClientOrganization(provider, organization); } - var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions + var customer = await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Description = string.Empty, Email = organization.BillingEmail, @@ -138,7 +138,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await _stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); organization.GatewaySubscriptionId = subscription.Id; organization.Status = OrganizationStatusType.Created; @@ -148,27 +148,26 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv } else if (organization.IsStripeEnabled()) { - var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions + var subscription = await _stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer"] }); - if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired) { return; } - await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + await _stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail }); if (subscription.Customer.Discount?.Coupon != null) { - await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId); + await _stripeAdapter.DeleteCustomerDiscountAsync(subscription.CustomerId); } - await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions + await _stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, DaysUntilDue = 30, diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 89ef251fd6..3d18e95f7b 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -427,7 +428,7 @@ public class ProviderService : IProviderService if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions + await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = provider.BillingEmail }); @@ -487,7 +488,7 @@ public class ProviderService : IProviderService private async Task GetSubscriptionItemAsync(string subscriptionId, string oldPlanId) { - var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId); + var subscriptionDetails = await _stripeAdapter.GetSubscriptionAsync(subscriptionId); return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId); } @@ -497,7 +498,7 @@ public class ProviderService : IProviderService { if (subscriptionItem.Price.Id != extractedPlanType) { - await _stripeAdapter.SubscriptionUpdateAsync(subscriptionItem.Subscription, + await _stripeAdapter.UpdateSubscriptionAsync(subscriptionItem.Subscription, new Stripe.SubscriptionUpdateOptions { Items = new List diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs index cc77797307..e140a13841 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Stripe; using Stripe.Tax; @@ -76,8 +75,8 @@ public class GetProviderWarningsQuery( // Get active and scheduled registrations var registrations = (await Task.WhenAll( - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) .SelectMany(registrations => registrations.Data); // Find the matching registration for the customer diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 8e8a89ae58..ce2f7a941f 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -101,7 +101,7 @@ public class BusinessUnitConverter( providerUser.Status = ProviderUserStatusType.Confirmed; // Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them. - await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { @@ -116,7 +116,7 @@ public class BusinessUnitConverter( ["convertedFrom"] = organization.Id.ToString() }; - var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions + var updateCustomer = stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { @@ -148,7 +148,7 @@ public class BusinessUnitConverter( // Replace the existing password manager price with the new business unit price. var updateSubscription = - stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { Items = [ diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index e352297f1e..41734663c2 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -61,11 +61,11 @@ public class ProviderBillingService( Organization organization, string key) { - await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); var subscription = - await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, + await stripeAdapter.CancelSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionCancelOptions { CancellationDetails = new SubscriptionCancellationDetailsOptions @@ -83,7 +83,7 @@ public class ProviderBillingService( if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft) { - await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, + await stripeAdapter.FinalizeInvoiceAsync(subscription.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = true }); } @@ -138,7 +138,7 @@ public class ProviderBillingService( if (clientCustomer.Balance != 0) { - await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, + await stripeAdapter.CreateCustomerBalanceTransactionAsync(provider.GatewayCustomerId, new CustomerBalanceTransactionCreateOptions { Amount = clientCustomer.Balance, @@ -187,7 +187,7 @@ public class ProviderBillingService( ] }; - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions); + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, updateOptions); // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // 1. Retrieve PlanType and PlanName for ProviderPlan @@ -275,7 +275,7 @@ public class ProviderBillingService( customerCreateOptions.TaxExempt = TaxExempt.Reverse; } - var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions); organization.GatewayCustomerId = customer.Id; @@ -525,7 +525,7 @@ public class ProviderBillingService( case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) @@ -558,7 +558,7 @@ public class ProviderBillingService( try { - return await stripeAdapter.CustomerCreateAsync(options); + return await stripeAdapter.CreateCustomerAsync(options); } catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid) { @@ -580,7 +580,7 @@ public class ProviderBillingService( case TokenizablePaymentMethodType.BankAccount: { var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); - await stripeAdapter.SetupIntentCancel(setupIntentId, + await stripeAdapter.CancelSetupIntentAsync(setupIntentId, new SetupIntentCancelOptions { CancellationReason = "abandoned" }); await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; @@ -638,7 +638,7 @@ public class ProviderBillingService( var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) - ? await stripeAdapter.SetupIntentGet(setupIntentId, + ? await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }) : null; @@ -673,7 +673,7 @@ public class ProviderBillingService( try { - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (subscription is { @@ -708,7 +708,7 @@ public class ProviderBillingService( subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource), subscriberService.UpdateTaxInformation(provider, taxInformation)); - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically }); } @@ -791,7 +791,7 @@ public class ProviderBillingService( if (subscriptionItemOptionsList.Count > 0) { - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); } } @@ -807,7 +807,7 @@ public class ProviderBillingService( var item = subscription.Items.First(item => item.Price.Id == priceId); - await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions + await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { Items = [ diff --git a/bitwarden_license/src/Scim/Users/PostUserCommand.cs b/bitwarden_license/src/Scim/Users/PostUserCommand.cs index 5b4a0c29cd..696d600348 100644 --- a/bitwarden_license/src/Scim/Users/PostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PostUserCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.E using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -24,7 +25,7 @@ public class PostUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, - IPaymentService paymentService, + IStripePaymentService paymentService, IScimContext scimContext, IFeatureService featureService, IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index b367b17c73..810429d658 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -131,7 +131,7 @@ public class RemoveOrganizationFromProviderCommandTests Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateCustomerAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -156,7 +156,7 @@ public class RemoveOrganizationFromProviderCommandTests "b@example.com" ]); - sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is( + sutProvider.GetDependency().GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is( options => options.Expand.Contains("customer"))) .Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId)); @@ -164,12 +164,14 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, + await stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Email == "a@example.com")); - await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId); + await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId); + + await stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.DaysUntilDue == 30)); @@ -226,7 +228,7 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Description == string.Empty && options.Email == organization.BillingEmail && options.Expand[0] == "tax" && @@ -239,14 +241,14 @@ public class RemoveOrganizationFromProviderCommandTests } }); - stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(new Subscription { Id = "subscription_id" }); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); - await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => + await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => options.Customer == organization.GatewayCustomerId && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.DaysUntilDue == 30 && @@ -315,7 +317,7 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Description == string.Empty && options.Email == organization.BillingEmail && options.Expand[0] == "tax" && @@ -328,14 +330,14 @@ public class RemoveOrganizationFromProviderCommandTests } }); - stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(new Subscription { Id = "subscription_id" }); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); - await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => + await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => options.Customer == organization.GatewayCustomerId && options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && options.DaysUntilDue == 30 && @@ -434,7 +436,7 @@ public class RemoveOrganizationFromProviderCommandTests var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any()) + stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(new Customer { Id = "customer_id", @@ -444,7 +446,7 @@ public class RemoveOrganizationFromProviderCommandTests } }); - stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(new Subscription { Id = "new_subscription_id" }); diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 78376f6d98..11ffe115e2 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -12,6 +12,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -757,7 +758,7 @@ public class ProviderServiceTests await organizationRepository.Received(1) .ReplaceAsync(Arg.Is(org => org.BillingEmail == provider.BillingEmail)); - await sutProvider.GetDependency().Received(1).CustomerUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateCustomerAsync( organization.GatewayCustomerId, Arg.Is(options => options.Email == provider.BillingEmail)); @@ -828,9 +829,9 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId); - sutProvider.GetDependency().SubscriptionGetAsync(organization.GatewaySubscriptionId) + sutProvider.GetDependency().GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(GetSubscription(organization.GatewaySubscriptionId)); - await sutProvider.GetDependency().SubscriptionUpdateAsync( + await sutProvider.GetDependency().UpdateSubscriptionAsync( organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem)); await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs index a7f896ef7a..96dbacfa92 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -63,7 +62,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -95,7 +94,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -129,7 +128,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -163,7 +162,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -224,7 +223,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "GB" }] @@ -257,7 +256,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -296,7 +295,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -338,7 +337,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -383,7 +382,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -428,7 +427,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -461,7 +460,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Active)) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Active)) .Returns(new StripeList { Data = [ @@ -470,7 +469,7 @@ public class GetProviderWarningsQueryTests new Registration { Country = "FR" } ] }); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Scheduled)) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Scheduled)) .Returns(new StripeList { Data = [] }); var response = await sutProvider.Sut.Run(provider); @@ -505,7 +504,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "CA" }] @@ -543,7 +542,7 @@ public class GetProviderWarningsQueryTests }); sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); - sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + sutProvider.GetDependency().ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = [new Registration { Country = "US" }] diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index c893886083..48b971a032 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -144,11 +144,11 @@ public class BusinessUnitConverterTests await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey); - await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any()); + await _stripeAdapter.Received(2).UpdateCustomerAsync(subscription.CustomerId, Arg.Any()); var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is( + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, Arg.Is( arguments => arguments.Items.Count == 2 && arguments.Items[0].Id == "subscription_item_id" && diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index daf35e7ae9..76c5b30dd8 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -20,7 +20,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; @@ -85,7 +84,7 @@ public class ProviderBillingServiceTests // Assert await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); - await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -113,7 +112,7 @@ public class ProviderBillingServiceTests // Assert await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any()); - await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -180,14 +179,14 @@ public class ProviderBillingServiceTests var stripeAdapter = sutProvider.GetDependency(); await stripeAdapter.Received(1) - .SubscriptionUpdateAsync( + .UpdateSubscriptionAsync( Arg.Is(provider.GatewaySubscriptionId), Arg.Is(p => p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1)); var newPlanCfg = MockPlans.Get(command.NewPlan); await stripeAdapter.Received(1) - .SubscriptionUpdateAsync( + .UpdateSubscriptionAsync( Arg.Is(provider.GatewaySubscriptionId), Arg.Is(p => p.Items.Count(si => @@ -268,7 +267,7 @@ public class ProviderBillingServiceTests CloudRegion = "US" }); - sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -288,7 +287,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -349,7 +348,7 @@ public class ProviderBillingServiceTests CloudRegion = "US" }); - sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -370,7 +369,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization); - await sutProvider.GetDependency().Received(1).CustomerCreateAsync(Arg.Is( + await sutProvider.GetDependency().Received(1).CreateCustomerAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && options.Address.PostalCode == providerCustomer.Address.PostalCode && @@ -535,7 +534,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync( + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync( Arg.Any(), Arg.Any()); @@ -619,7 +618,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 95 current + 10 seat scale = 105 seats, 5 above the minimum - await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateSubscriptionAsync( provider.GatewaySubscriptionId, Arg.Is( options => @@ -707,7 +706,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10); // 110 current + 10 seat scale up = 120 seats - await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateSubscriptionAsync( provider.GatewaySubscriptionId, Arg.Is( options => @@ -795,7 +794,7 @@ public class ProviderBillingServiceTests await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30); // 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum. - await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + await sutProvider.GetDependency().Received(1).UpdateSubscriptionAsync( provider.GatewaySubscriptionId, Arg.Is( options => @@ -914,12 +913,12 @@ public class ProviderBillingServiceTests var stripeAdapter = sutProvider.GetDependency(); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; - stripeAdapter.SetupIntentList(Arg.Is(options => + stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -942,7 +941,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); - await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => + await stripeAdapter.Received(1).CancelSetupIntentAsync("setup_intent_id", Arg.Is(options => options.CancellationReason == "abandoned")); await sutProvider.GetDependency().Received(1).RemoveSetupIntentForSubscriber(provider.Id); @@ -964,7 +963,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1007,12 +1006,12 @@ public class ProviderBillingServiceTests var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; - stripeAdapter.SetupIntentList(Arg.Is(options => + stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1058,7 +1057,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1100,7 +1099,7 @@ public class ProviderBillingServiceTests var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1142,7 +1141,7 @@ public class ProviderBillingServiceTests var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - stripeAdapter.CustomerCreateAsync(Arg.Is(o => + stripeAdapter.CreateCustomerAsync(Arg.Is(o => o.Address.Country == billingAddress.Country && o.Address.PostalCode == billingAddress.PostalCode && o.Address.Line1 == billingAddress.Line1 && @@ -1178,7 +1177,7 @@ public class ProviderBillingServiceTests var stripeAdapter = sutProvider.GetDependency(); var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - stripeAdapter.CustomerCreateAsync(Arg.Any()) + stripeAdapter.CreateCustomerAsync(Arg.Any()) .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } }); var actual = await Assert.ThrowsAsync(async () => @@ -1216,7 +1215,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1244,7 +1243,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1272,7 +1271,7 @@ public class ProviderBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1323,7 +1322,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetByProviderId(provider.Id) .Returns(providerPlans); - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Any()) + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Any()) .Returns( new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Incomplete }); @@ -1381,7 +1380,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice && @@ -1458,7 +1457,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1538,7 +1537,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId); - sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => + sutProvider.GetDependency().GetSetupIntentAsync(setupIntentId, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent { Id = setupIntentId, @@ -1553,7 +1552,7 @@ public class ProviderBillingServiceTests } }); - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1635,7 +1634,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1713,7 +1712,7 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( + sutProvider.GetDependency().CreateSubscriptionAsync(Arg.Is( sub => sub.AutomaticTax.Enabled == true && sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically && @@ -1828,7 +1827,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 20 && providerPlan.PurchasedSeats == 5)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && @@ -1908,7 +1907,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && @@ -1989,7 +1988,7 @@ public class ProviderBillingServiceTests providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); await stripeAdapter.DidNotReceiveWithAnyArgs() - .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -2062,7 +2061,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 2 && @@ -2142,7 +2141,7 @@ public class ProviderBillingServiceTests await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is( providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Items.Count == 1 && diff --git a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs index ac23e7ecc1..eb8804cac5 100644 --- a/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PostUserCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -36,7 +37,7 @@ public class PostUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization); - sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); + sutProvider.GetDependency().HasSecretsManagerStandalone(organization).Returns(true); sutProvider.GetDependency() .InviteUserAsync(organizationId, diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 2ea539f39f..a99f70bf65 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -16,6 +16,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -41,7 +42,7 @@ public class OrganizationsController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IPolicyRepository _policyRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IApplicationCacheService _applicationCacheService; private readonly GlobalSettings _globalSettings; private readonly IProviderRepository _providerRepository; @@ -66,7 +67,7 @@ public class OrganizationsController : Controller ICollectionRepository collectionRepository, IGroupRepository groupRepository, IPolicyRepository policyRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IApplicationCacheService applicationCacheService, GlobalSettings globalSettings, IProviderRepository providerRepository, diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 9344179a77..b6a959a386 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -339,11 +339,11 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); - var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId); if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) { var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; - await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = new Dictionary { diff --git a/src/Admin/Controllers/ToolsController.cs b/src/Admin/Controllers/ToolsController.cs index 46dafd65e7..2dd6de89a0 100644 --- a/src/Admin/Controllers/ToolsController.cs +++ b/src/Admin/Controllers/ToolsController.cs @@ -8,6 +8,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; diff --git a/src/Admin/Controllers/UsersController.cs b/src/Admin/Controllers/UsersController.cs index b85a91719c..f42b22b098 100644 --- a/src/Admin/Controllers/UsersController.cs +++ b/src/Admin/Controllers/UsersController.cs @@ -5,6 +5,7 @@ using Bit.Admin.Models; using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -20,7 +21,7 @@ public class UsersController : Controller { private readonly IUserRepository _userRepository; private readonly ICipherRepository _cipherRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly GlobalSettings _globalSettings; private readonly IAccessControlService _accessControlService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; @@ -30,7 +31,7 @@ public class UsersController : Controller public UsersController( IUserRepository userRepository, ICipherRepository cipherRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, GlobalSettings globalSettings, IAccessControlService accessControlService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 3b2e82121d..58e5db18c2 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,7 +25,7 @@ public class MembersController : Controller private readonly ICurrentContext _currentContext; private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; @@ -37,7 +38,7 @@ public class MembersController : Controller ICurrentContext currentContext, IUpdateOrganizationUserCommand updateOrganizationUserCommand, IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, - IPaymentService paymentService, + IStripePaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 99b6a47da0..243f4d3c53 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -10,7 +10,7 @@ namespace Bit.Api.Billing.Controllers; [Route("accounts/billing")] [Authorize("Application")] public class AccountsBillingController( - IPaymentService paymentService, + IStripePaymentService paymentService, IUserService userService, IPaymentHistoryService paymentHistoryService) : Controller { diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index e136513c77..5d3e095fdd 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -79,7 +79,7 @@ public class AccountsController( [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, - [FromServices] IPaymentService paymentService) + [FromServices] IStripePaymentService paymentService) { var user = await userService.GetUserByPrincipalAsync(User); if (user == null) diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index a0a3e48b60..e06d946ea0 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -5,7 +5,6 @@ using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,7 +18,7 @@ public class OrganizationBillingController( ICurrentContext currentContext, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IPaymentHistoryService paymentHistoryService) : BaseBillingController { // TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed. diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 16fb00a3e7..bca5605a8c 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -36,7 +36,7 @@ public class OrganizationsController( IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, IUserService userService, - IPaymentService paymentService, + IStripePaymentService paymentService, ICurrentContext currentContext, IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, GlobalSettings globalSettings, diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index d358f8efd2..dfa705a329 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -43,7 +43,7 @@ public class ProviderBillingController( return result; } - var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var invoices = await stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = provider.GatewayCustomerId }); @@ -87,7 +87,7 @@ public class ProviderBillingController( return result; } - var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, + var subscription = await stripeAdapter.GetSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] }); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); @@ -96,7 +96,7 @@ public class ProviderBillingController( { var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); - var price = await stripeAdapter.PriceGetAsync(priceId); + var price = await stripeAdapter.GetPriceAsync(priceId); var unitAmount = price.UnitAmountDecimal.HasValue ? price.UnitAmountDecimal.Value / 100M diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index 15fccd16f4..6cb10e3165 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -1,5 +1,5 @@ -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Tax.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -28,7 +28,7 @@ public class StripeController( Usage = "off_session" }; - var setupIntent = await stripeAdapter.SetupIntentCreate(options); + var setupIntent = await stripeAdapter.CreateSetupIntentAsync(options); return TypedResults.Ok(setupIntent.ClientSecret); } @@ -43,7 +43,7 @@ public class StripeController( Usage = "off_session" }; - var setupIntent = await stripeAdapter.SetupIntentCreate(options); + var setupIntent = await stripeAdapter.CreateSetupIntentAsync(options); return TypedResults.Ok(setupIntent.ClientSecret); } diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index b24a8d8c36..f55b4523af 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -29,7 +29,7 @@ public class BitPayController( IUserRepository userRepository, IProviderRepository providerRepository, IMailService mailService, - IPaymentService paymentService, + IStripePaymentService paymentService, ILogger logger, IPremiumUserBillingService premiumUserBillingService) : Controller diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 8039680fd5..70023b6bdb 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -23,7 +23,7 @@ public class PayPalController : Controller private readonly ILogger _logger; private readonly IMailService _mailService; private readonly IOrganizationRepository _organizationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ITransactionRepository _transactionRepository; private readonly IUserRepository _userRepository; private readonly IProviderRepository _providerRepository; @@ -34,7 +34,7 @@ public class PayPalController : Controller ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, ITransactionRepository transactionRepository, IUserRepository userRepository, IProviderRepository providerRepository, diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs index bc3fa1bd56..89e40f0e43 100644 --- a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -2,8 +2,8 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using OneOf; using Stripe; using Event = Stripe.Event; @@ -59,10 +59,10 @@ public class SetupIntentSucceededHandler( return; } - await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id, + await stripeAdapter.AttachPaymentMethodAsync(paymentMethod.Id, new PaymentMethodAttachOptions { Customer = customerId }); - await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(customerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs index a78dd95260..b9bad6a346 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -18,7 +19,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; private readonly IOrganizationService _organizationService; @@ -27,7 +28,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IGroupRepository groupRepository, IEventService eventService, IOrganizationService organizationService) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index f8bd988cab..2648a2e429 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -2,10 +2,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Billing.Services; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -15,7 +15,7 @@ public class InviteOrganizationUsersValidator( IOrganizationRepository organizationRepository, IInviteUsersPasswordManagerValidator inviteUsersPasswordManagerValidator, IUpdateSecretsManagerSubscriptionCommand secretsManagerSubscriptionCommand, - IPaymentService paymentService) : IInviteUsersValidator + IStripePaymentService paymentService) : IInviteUsersValidator { public async Task> ValidateAsync( InviteOrganizationUsersValidationRequest request) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index 67155fe91a..9ba2fd1596 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -9,8 +9,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.V using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; @@ -22,7 +22,7 @@ public class InviteUsersPasswordManagerValidator( IInviteUsersEnvironmentValidator inviteUsersEnvironmentValidator, IInviteUsersOrganizationValidator inviteUsersOrganizationValidator, IProviderRepository providerRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IOrganizationRepository organizationRepository ) : IInviteUsersPasswordManagerValidator { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 0cae0fcc81..154c3b7319 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -33,7 +34,7 @@ public interface ICloudOrganizationSignUpCommand public class CloudOrganizationSignUpCommand( IOrganizationUserRepository organizationUserRepository, IOrganizationBillingService organizationBillingService, - IPaymentService paymentService, + IStripePaymentService paymentService, IPolicyService policyService, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index 6a81130402..f73c49c811 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; @@ -12,13 +13,13 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand { private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ISsoConfigRepository _ssoConfigRepository; public OrganizationDeleteCommand( IApplicationCacheService applicationCacheService, IOrganizationRepository organizationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, ISsoConfigRepository ssoConfigRepository) { _applicationCacheService = applicationCacheService; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs index 446d7339ca..82260aa6a7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -39,7 +40,7 @@ public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizati private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; public ResellerClientOrganizationSignUpCommand( IOrganizationRepository organizationRepository, @@ -48,7 +49,7 @@ public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizati IOrganizationUserRepository organizationUserRepository, IEventService eventService, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, - IPaymentService paymentService) + IStripePaymentService paymentService) { _organizationRepository = organizationRepository; _organizationApiKeyRepository = organizationApiKeyRepository; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs index c52b7c10c9..6a7d068ae1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -30,7 +30,7 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private readonly ILicensingService _licensingService; private readonly IPolicyService _policyService; private readonly IGlobalSettings _globalSettings; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; public SelfHostedOrganizationSignUpCommand( IOrganizationRepository organizationRepository, @@ -44,7 +44,7 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp ILicensingService licensingService, IPolicyService policyService, IGlobalSettings globalSettings, - IPaymentService paymentService) + IStripePaymentService paymentService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs index 450f425bdf..e4d5a94c4c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs @@ -1,12 +1,12 @@ using Bit.Core.AdminConsole.Models.Data.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; -public class UpdateOrganizationSubscriptionCommand(IPaymentService paymentService, +public class UpdateOrganizationSubscriptionCommand(IStripePaymentService paymentService, IOrganizationRepository repository, TimeProvider timeProvider, ILogger logger) : IUpdateOrganizationSubscriptionCommand diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f18ecf341b..e1fcbb970d 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -21,6 +21,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -47,7 +48,7 @@ public class OrganizationService : IOrganizationService private readonly IPushNotificationService _pushNotificationService; private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; @@ -74,7 +75,7 @@ public class OrganizationService : IOrganizationService IPushNotificationService pushNotificationService, IEventService eventService, IApplicationCacheService applicationCacheService, - IPaymentService paymentService, + IStripePaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, ISsoUserRepository ssoUserRepository, @@ -358,7 +359,7 @@ public class OrganizationService : IOrganizationService { var newDisplayName = organization.DisplayName(); - await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, + await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail, diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 143da0d67f..2a5e786c98 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -7,8 +7,8 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Enums; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf; using Stripe; @@ -125,7 +125,7 @@ public class PreviewOrganizationTaxCommand( options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); }); @@ -165,7 +165,7 @@ public class PreviewOrganizationTaxCommand( options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); } else @@ -181,7 +181,7 @@ public class PreviewOrganizationTaxCommand( var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); - var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + var subscription = await stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer"] }); if (subscription.Customer.Discount != null) @@ -259,7 +259,7 @@ public class PreviewOrganizationTaxCommand( options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); } }); @@ -278,7 +278,7 @@ public class PreviewOrganizationTaxCommand( return new BadRequest("Organization does not have a subscription."); } - var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + var subscription = await stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); var options = GetBaseOptions(subscription.Customer, @@ -336,7 +336,7 @@ public class PreviewOrganizationTaxCommand( options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); }); diff --git a/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs index f00bc00356..a8a236decc 100644 --- a/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs @@ -22,14 +22,14 @@ public interface IGetCloudOrganizationLicenseQuery public class GetCloudOrganizationLicenseQuery : IGetCloudOrganizationLicenseQuery { private readonly IInstallationRepository _installationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ILicensingService _licensingService; private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; public GetCloudOrganizationLicenseQuery( IInstallationRepository installationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, ILicensingService licensingService, IProviderRepository providerRepository, IFeatureService featureService) diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 01e520ea41..af8dfa7aec 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -9,7 +9,6 @@ using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Stripe; using Stripe.Tax; @@ -201,7 +200,7 @@ public class GetOrganizationWarningsQuery( // ReSharper disable once InvertIf if (subscription.Status == SubscriptionStatus.PastDue) { - var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + var openInvoices = await stripeAdapter.SearchInvoiceAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); @@ -257,8 +256,8 @@ public class GetOrganizationWarningsQuery( // Get active and scheduled registrations var registrations = (await Task.WhenAll( - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), - stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) .SelectMany(registrations => registrations.Data); // Find the matching registration for the customer diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 65c339fad4..162fb488f6 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -14,7 +14,6 @@ using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Braintree; using Microsoft.Extensions.Logging; @@ -161,7 +160,7 @@ public class OrganizationBillingService( try { // Update the subscription in Stripe - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, updateOptions); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions); organization.PlanType = newPlan.Type; await organizationRepository.ReplaceAsync(organization); } @@ -185,7 +184,7 @@ public class OrganizationBillingService( var newDisplayName = organization.DisplayName(); - await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, + await stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail, @@ -324,7 +323,7 @@ public class OrganizationBillingService( case PaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) .FirstOrDefault(); if (setupIntent == null) @@ -358,7 +357,7 @@ public class OrganizationBillingService( try { - var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions); organization.Gateway = GatewayType.Stripe; organization.GatewayCustomerId = customer.Id; @@ -509,7 +508,7 @@ public class OrganizationBillingService( subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); organization.GatewaySubscriptionId = subscription.Id; await organizationRepository.ReplaceAsync(organization); @@ -537,14 +536,14 @@ public class OrganizationBillingService( customer = customer switch { { Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await - stripeAdapter.CustomerUpdateAsync(customer.Id, + stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, TaxExempt = StripeConstants.TaxExempt.Reverse }), { Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await - stripeAdapter.CustomerUpdateAsync(customer.Id, + stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, @@ -603,7 +602,7 @@ public class OrganizationBillingService( } } }; - await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, options); + await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, options); } } diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs index f4eca40cae..daf39fb981 100644 --- a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; @@ -46,7 +45,7 @@ public class UpdateBillingAddressCommand( BillingAddress billingAddress) { var customer = - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { Address = new AddressOptions @@ -71,7 +70,7 @@ public class UpdateBillingAddressCommand( BillingAddress billingAddress) { var customer = - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { Address = new AddressOptions @@ -92,7 +91,7 @@ public class UpdateBillingAddressCommand( await EnableAutomaticTaxAsync(subscriber, customer); var deleteExistingTaxIds = customer.TaxIds?.Any() ?? false - ? customer.TaxIds.Select(taxId => stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id)).ToList() + ? customer.TaxIds.Select(taxId => stripeAdapter.DeleteTaxIdAsync(customer.Id, taxId.Id)).ToList() : []; if (billingAddress.TaxId == null) @@ -101,12 +100,12 @@ public class UpdateBillingAddressCommand( return BillingAddress.From(customer.Address); } - var updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + var updatedTaxId = await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }); if (billingAddress.TaxId.Code == StripeConstants.TaxIdType.SpanishNIF) { - updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + updatedTaxId = await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, @@ -130,7 +129,7 @@ public class UpdateBillingAddressCommand( if (subscription is { AutomaticTax.Enabled: false }) { - await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + await stripeAdapter.UpdateSubscriptionAsync(subscriber.GatewaySubscriptionId, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs index 81206b8032..a5a9e3e9c9 100644 --- a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -56,7 +55,7 @@ public class UpdatePaymentMethodCommand( if (billingAddress != null && customer.Address is not { Country: not null, PostalCode: not null }) { - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Address = new AddressOptions @@ -75,7 +74,7 @@ public class UpdatePaymentMethodCommand( Customer customer, string token) { - var setupIntents = await stripeAdapter.SetupIntentList(new SetupIntentListOptions + var setupIntents = await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { Expand = ["data.payment_method"], PaymentMethod = token @@ -104,9 +103,9 @@ public class UpdatePaymentMethodCommand( Customer customer, string token) { - var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id }); + var paymentMethod = await stripeAdapter.AttachPaymentMethodAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id }); - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token } @@ -139,7 +138,7 @@ public class UpdatePaymentMethodCommand( [StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomer.Id }; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); } var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount; @@ -204,7 +203,7 @@ public class UpdatePaymentMethodCommand( [StripeConstants.MetadataKeys.BraintreeCustomerId] = string.Empty }; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); } } } diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs index 9f9618571e..e03a785278 100644 --- a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Braintree; using Microsoft.Extensions.Logging; using Stripe; @@ -53,7 +52,7 @@ public class GetPaymentMethodQuery( if (!string.IsNullOrEmpty(setupIntentId)) { - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }); diff --git a/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs index ec77ee0712..c972c3fe5f 100644 --- a/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs @@ -3,7 +3,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Entities; -using Bit.Core.Services; using Stripe; namespace Bit.Core.Billing.Payment.Queries; @@ -48,7 +47,7 @@ public class HasPaymentMethodQuery( return false; } - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }); diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 472f31ac4b..ed60e2f11c 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -210,7 +210,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token })) + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token })) .FirstOrDefault(); if (setupIntent == null) @@ -243,7 +243,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( try { - return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + return await stripeAdapter.CreateCustomerAsync(customerCreateOptions); } catch { @@ -300,7 +300,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ValidateLocation = ValidateTaxLocationTiming.Immediately } }; - return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + return await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } private async Task CreateSubscriptionAsync( @@ -349,11 +349,11 @@ public class CreatePremiumCloudHostedSubscriptionCommand( OffSession = true }; - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (usingPayPal) { - await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions { AutoAdvance = false }); diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 5f09b8b77b..07247c83cb 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Services; +using Bit.Core.Billing.Services; using Microsoft.Extensions.Logging; using Stripe; @@ -56,7 +56,7 @@ public class PreviewPremiumTaxCommand( }); } - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); }); diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs new file mode 100644 index 0000000000..5ec732920e --- /dev/null +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -0,0 +1,50 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.BitStripe; +using Stripe; +using Stripe.Tax; + +namespace Bit.Core.Billing.Services; + +public interface IStripeAdapter +{ + Task CreateCustomerAsync(CustomerCreateOptions customerCreateOptions); + Task GetCustomerAsync(string id, CustomerGetOptions options = null); + Task UpdateCustomerAsync(string id, CustomerUpdateOptions options = null); + Task DeleteCustomerAsync(string id); + Task> ListCustomerPaymentMethodsAsync(string id, CustomerPaymentMethodListOptions options = null); + Task CreateCustomerBalanceTransactionAsync(string customerId, + CustomerBalanceTransactionCreateOptions options); + Task CreateSubscriptionAsync(SubscriptionCreateOptions subscriptionCreateOptions); + Task GetSubscriptionAsync(string id, SubscriptionGetOptions options = null); + Task> ListTaxRegistrationsAsync(RegistrationListOptions options = null); + Task DeleteCustomerDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null); + Task UpdateSubscriptionAsync(string id, SubscriptionUpdateOptions options = null); + Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null); + Task GetInvoiceAsync(string id, InvoiceGetOptions options); + Task> ListInvoicesAsync(StripeInvoiceListOptions options); + Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options); + Task> SearchInvoiceAsync(InvoiceSearchOptions options); + Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options); + Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options); + Task SendInvoiceAsync(string id, InvoiceSendOptions options); + Task PayInvoiceAsync(string id, InvoicePayOptions options = null); + Task DeleteInvoiceAsync(string id, InvoiceDeleteOptions options = null); + Task VoidInvoiceAsync(string id, InvoiceVoidOptions options = null); + IEnumerable ListPaymentMethodsAutoPaging(PaymentMethodListOptions options); + IAsyncEnumerable ListPaymentMethodsAutoPagingAsync(PaymentMethodListOptions options); + Task AttachPaymentMethodAsync(string id, PaymentMethodAttachOptions options = null); + Task DetachPaymentMethodAsync(string id, PaymentMethodDetachOptions options = null); + Task CreateTaxIdAsync(string id, TaxIdCreateOptions options); + Task DeleteTaxIdAsync(string customerId, string taxIdId, TaxIdDeleteOptions options = null); + Task> ListChargesAsync(ChargeListOptions options); + Task CreateRefundAsync(RefundCreateOptions options); + Task DeleteCardAsync(string customerId, string cardId, CardDeleteOptions options = null); + Task DeleteBankAccountAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null); + Task CreateSetupIntentAsync(SetupIntentCreateOptions options); + Task> ListSetupIntentsAsync(SetupIntentListOptions options); + Task CancelSetupIntentAsync(string id, SetupIntentCancelOptions options = null); + Task GetSetupIntentAsync(string id, SetupIntentGetOptions options = null); + Task GetPriceAsync(string id, PriceGetOptions options = null); +} diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Billing/Services/IStripePaymentService.cs similarity index 97% rename from src/Core/Services/IPaymentService.cs rename to src/Core/Billing/Services/IStripePaymentService.cs index b4a4639992..b948cf6921 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Billing/Services/IStripePaymentService.cs @@ -8,9 +8,9 @@ using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; -public interface IPaymentService +public interface IStripePaymentService { Task CancelAndRecoverChargesAsync(ISubscriber subscriber); Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); diff --git a/src/Core/Billing/Services/IStripeSyncService.cs b/src/Core/Billing/Services/IStripeSyncService.cs new file mode 100644 index 0000000000..b56204cd47 --- /dev/null +++ b/src/Core/Billing/Services/IStripeSyncService.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Billing.Services; + +public interface IStripeSyncService +{ + Task UpdateCustomerEmailAddressAsync(string gatewayCustomerId, string emailAddress); +} diff --git a/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs b/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs index 5a8cf16f5a..16b3f7e0c3 100644 --- a/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs +++ b/src/Core/Billing/Services/Implementations/PaymentHistoryService.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Models.BitStripe; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.Billing.Services.Implementations; @@ -23,7 +22,7 @@ public class PaymentHistoryService( return Array.Empty(); } - var invoices = await stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var invoices = await stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = subscriber.GatewayCustomerId, Limit = pageSize, diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index daa06b907a..9c85971dff 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -12,7 +12,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Braintree; using Microsoft.Extensions.Logging; @@ -68,7 +67,7 @@ public class PremiumUserBillingService( } }; - customer = await stripeAdapter.CustomerCreateAsync(options); + customer = await stripeAdapter.CreateCustomerAsync(options); user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; @@ -81,7 +80,7 @@ public class PremiumUserBillingService( Balance = customer.Balance + credit }; - await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } } @@ -227,7 +226,7 @@ public class PremiumUserBillingService( case PaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) + (await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) .FirstOrDefault(); if (setupIntent == null) @@ -260,7 +259,7 @@ public class PremiumUserBillingService( try { - return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + return await stripeAdapter.CreateCustomerAsync(customerCreateOptions); } catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) @@ -347,11 +346,11 @@ public class PremiumUserBillingService( OffSession = true }; - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (usingPayPal) { - await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions { AutoAdvance = false }); @@ -387,6 +386,6 @@ public class PremiumUserBillingService( } }; - return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + return await stripeAdapter.UpdateCustomerAsync(customer.Id, options); } } diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs new file mode 100644 index 0000000000..cdc7645042 --- /dev/null +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -0,0 +1,209 @@ +// FIXME: Update this file to be null safe and then delete the line below + +#nullable disable + +using Bit.Core.Models.BitStripe; +using Stripe; +using Stripe.Tax; +using Stripe.TestHelpers; +using CustomerService = Stripe.CustomerService; +using RefundService = Stripe.RefundService; + +namespace Bit.Core.Billing.Services.Implementations; + +public class StripeAdapter : IStripeAdapter +{ + private readonly CustomerService _customerService; + private readonly SubscriptionService _subscriptionService; + private readonly InvoiceService _invoiceService; + private readonly PaymentMethodService _paymentMethodService; + private readonly TaxIdService _taxIdService; + private readonly ChargeService _chargeService; + private readonly RefundService _refundService; + private readonly CardService _cardService; + private readonly BankAccountService _bankAccountService; + private readonly PriceService _priceService; + private readonly SetupIntentService _setupIntentService; + private readonly TestClockService _testClockService; + private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; + private readonly RegistrationService _taxRegistrationService; + + public StripeAdapter() + { + _customerService = new CustomerService(); + _subscriptionService = new SubscriptionService(); + _invoiceService = new InvoiceService(); + _paymentMethodService = new PaymentMethodService(); + _taxIdService = new TaxIdService(); + _chargeService = new ChargeService(); + _refundService = new RefundService(); + _cardService = new CardService(); + _bankAccountService = new BankAccountService(); + _priceService = new PriceService(); + _setupIntentService = new SetupIntentService(); + _testClockService = new TestClockService(); + _customerBalanceTransactionService = new CustomerBalanceTransactionService(); + _taxRegistrationService = new RegistrationService(); + } + + /************** + ** CUSTOMER ** + **************/ + public Task CreateCustomerAsync(CustomerCreateOptions options) => + _customerService.CreateAsync(options); + + public Task DeleteCustomerDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null) => + _customerService.DeleteDiscountAsync(customerId, options); + + public Task GetCustomerAsync(string id, CustomerGetOptions options = null) => + _customerService.GetAsync(id, options); + + public Task UpdateCustomerAsync(string id, CustomerUpdateOptions options = null) => + _customerService.UpdateAsync(id, options); + + public Task DeleteCustomerAsync(string id) => + _customerService.DeleteAsync(id); + + public async Task> ListCustomerPaymentMethodsAsync(string id, + CustomerPaymentMethodListOptions options = null) + { + var paymentMethods = await _customerService.ListPaymentMethodsAsync(id, options); + return paymentMethods.Data; + } + + public Task CreateCustomerBalanceTransactionAsync(string customerId, + CustomerBalanceTransactionCreateOptions options) => + _customerBalanceTransactionService.CreateAsync(customerId, options); + + /****************** + ** SUBSCRIPTION ** + ******************/ + public Task CreateSubscriptionAsync(SubscriptionCreateOptions options) => + _subscriptionService.CreateAsync(options); + + public Task GetSubscriptionAsync(string id, SubscriptionGetOptions options = null) => + _subscriptionService.GetAsync(id, options); + + public Task UpdateSubscriptionAsync(string id, + SubscriptionUpdateOptions options = null) => + _subscriptionService.UpdateAsync(id, options); + + public Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null) => + _subscriptionService.CancelAsync(id, options); + + /************* + ** INVOICE ** + *************/ + public Task GetInvoiceAsync(string id, InvoiceGetOptions options) => + _invoiceService.GetAsync(id, options); + + public async Task> ListInvoicesAsync(StripeInvoiceListOptions options) + { + if (!options.SelectAll) + { + return (await _invoiceService.ListAsync(options.ToInvoiceListOptions())).Data; + } + + options.Limit = 100; + + var invoices = new List(); + + await foreach (var invoice in _invoiceService.ListAutoPagingAsync(options.ToInvoiceListOptions())) + { + invoices.Add(invoice); + } + + return invoices; + } + + public Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options) => + _invoiceService.CreatePreviewAsync(options); + + public async Task> SearchInvoiceAsync(InvoiceSearchOptions options) => + (await _invoiceService.SearchAsync(options)).Data; + + public Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options) => + _invoiceService.UpdateAsync(id, options); + + public Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options) => + _invoiceService.FinalizeInvoiceAsync(id, options); + + public Task SendInvoiceAsync(string id, InvoiceSendOptions options) => + _invoiceService.SendInvoiceAsync(id, options); + + public Task PayInvoiceAsync(string id, InvoicePayOptions options = null) => + _invoiceService.PayAsync(id, options); + + public Task DeleteInvoiceAsync(string id, InvoiceDeleteOptions options = null) => + _invoiceService.DeleteAsync(id, options); + + public Task VoidInvoiceAsync(string id, InvoiceVoidOptions options = null) => + _invoiceService.VoidInvoiceAsync(id, options); + + /******************** + ** PAYMENT METHOD ** + ********************/ + public IEnumerable ListPaymentMethodsAutoPaging(PaymentMethodListOptions options) => + _paymentMethodService.ListAutoPaging(options); + + public IAsyncEnumerable ListPaymentMethodsAutoPagingAsync(PaymentMethodListOptions options) + => _paymentMethodService.ListAutoPagingAsync(options); + + public Task AttachPaymentMethodAsync(string id, PaymentMethodAttachOptions options = null) => + _paymentMethodService.AttachAsync(id, options); + + public Task DetachPaymentMethodAsync(string id, PaymentMethodDetachOptions options = null) => + _paymentMethodService.DetachAsync(id, options); + + /************ + ** TAX ID ** + ************/ + public Task CreateTaxIdAsync(string id, TaxIdCreateOptions options) => + _taxIdService.CreateAsync(id, options); + + public Task DeleteTaxIdAsync(string customerId, string taxIdId, + TaxIdDeleteOptions options = null) => + _taxIdService.DeleteAsync(customerId, taxIdId, options); + + /****************** + ** BANK ACCOUNT ** + ******************/ + public Task DeleteBankAccountAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null) => + _bankAccountService.DeleteAsync(customerId, bankAccount, options); + + /*********** + ** PRICE ** + ***********/ + public Task GetPriceAsync(string id, PriceGetOptions options = null) => + _priceService.GetAsync(id, options); + + /****************** + ** SETUP INTENT ** + ******************/ + public Task CreateSetupIntentAsync(SetupIntentCreateOptions options) => + _setupIntentService.CreateAsync(options); + + public async Task> ListSetupIntentsAsync(SetupIntentListOptions options) => + (await _setupIntentService.ListAsync(options)).Data; + + public Task CancelSetupIntentAsync(string id, SetupIntentCancelOptions options = null) => + _setupIntentService.CancelAsync(id, options); + + public Task GetSetupIntentAsync(string id, SetupIntentGetOptions options = null) => + _setupIntentService.GetAsync(id, options); + + /******************* + ** MISCELLANEOUS ** + *******************/ + public Task> ListChargesAsync(ChargeListOptions options) => + _chargeService.ListAsync(options); + + public Task> ListTaxRegistrationsAsync(RegistrationListOptions options = null) => + _taxRegistrationService.ListAsync(options); + + public Task CreateRefundAsync(RefundCreateOptions options) => + _refundService.CreateAsync(options); + + public Task DeleteCardAsync(string customerId, string cardId, CardDeleteOptions options = null) => + _cardService.DeleteAsync(customerId, cardId, options); +} diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Billing/Services/Implementations/StripePaymentService.cs similarity index 90% rename from src/Core/Services/Implementations/StripePaymentService.cs rename to src/Core/Billing/Services/Implementations/StripePaymentService.cs index c887a388bd..ffc18aa748 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Billing/Services/Implementations/StripePaymentService.cs @@ -21,9 +21,9 @@ using Stripe; using PaymentMethod = Stripe.PaymentMethod; using StaticStore = Bit.Core.Models.StaticStore; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services.Implementations; -public class StripePaymentService : IPaymentService +public class StripePaymentService : IStripePaymentService { private const string SecretsManagerStandaloneDiscountId = "sm-standalone"; @@ -64,7 +64,7 @@ public class StripePaymentService : IPaymentService await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, true); - var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); + var sub = await _stripeAdapter.GetSubscriptionAsync(org.GatewaySubscriptionId); org.ExpirationDate = sub.GetCurrentPeriodEnd(); if (sponsorship is not null) @@ -84,7 +84,7 @@ public class StripePaymentService : IPaymentService { // remember, when in doubt, throw var subGetOptions = new SubscriptionGetOptions { Expand = ["customer.tax", "customer.tax_ids"] }; - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions); + var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, subGetOptions); if (sub == null) { throw new GatewayException("Subscription not found."); @@ -107,7 +107,7 @@ public class StripePaymentService : IPaymentService var subUpdateOptions = new SubscriptionUpdateOptions { Items = updatedItemOptions, - ProrationBehavior = invoiceNow ? Constants.AlwaysInvoice : Constants.CreateProrations, + ProrationBehavior = invoiceNow ? Core.Constants.AlwaysInvoice : Core.Constants.CreateProrations, DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice" }; @@ -121,11 +121,11 @@ public class StripePaymentService : IPaymentService { if (sub.Customer is { - Address.Country: not Constants.CountryAbbreviations.UnitedStates, + Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse }) { - await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, + await _stripeAdapter.UpdateCustomerAsync(sub.CustomerId, new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); } @@ -141,9 +141,9 @@ public class StripePaymentService : IPaymentService string paymentIntentClientSecret = null; try { - var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions); + var subResponse = await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, subUpdateOptions); - var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new InvoiceGetOptions()); + var invoice = await _stripeAdapter.GetInvoiceAsync(subResponse?.LatestInvoiceId, new InvoiceGetOptions()); if (invoice == null) { throw new BadRequestException("Unable to locate draft invoice for subscription update."); @@ -162,9 +162,9 @@ public class StripePaymentService : IPaymentService } else { - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, + invoice = await _stripeAdapter.FinalizeInvoiceAsync(subResponse.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = false, }); - await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new InvoiceSendOptions()); + await _stripeAdapter.SendInvoiceAsync(invoice.Id, new InvoiceSendOptions()); paymentIntentClientSecret = null; } } @@ -172,7 +172,7 @@ public class StripePaymentService : IPaymentService catch { // Need to revert the subscription - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions + await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { Items = subscriptionUpdate.RevertItemsOptions(sub), // This proration behavior prevents a false "credit" from @@ -187,7 +187,7 @@ public class StripePaymentService : IPaymentService else if (invoice.Status != StripeConstants.InvoiceStatus.Paid) { // Pay invoice with no charge to the customer this completes the invoice immediately without waiting the scheduled 1h - invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); + invoice = await _stripeAdapter.PayInvoiceAsync(subResponse.LatestInvoiceId); paymentIntentClientSecret = null; } } @@ -196,7 +196,7 @@ public class StripePaymentService : IPaymentService // Change back the subscription collection method and/or days until due if (collectionMethod != "send_invoice" || daysUntilDue == null) { - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { CollectionMethod = collectionMethod, @@ -204,14 +204,14 @@ public class StripePaymentService : IPaymentService }); } - var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(sub.CustomerId); var newCoupon = customer.Discount?.Coupon?.Id; if (!string.IsNullOrEmpty(existingCoupon) && string.IsNullOrEmpty(newCoupon)) { // Re-add the lost coupon due to the update. - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions + await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { Discounts = [ @@ -284,7 +284,7 @@ public class StripePaymentService : IPaymentService { if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { - await _stripeAdapter.SubscriptionCancelAsync(subscriber.GatewaySubscriptionId, + await _stripeAdapter.CancelSubscriptionAsync(subscriber.GatewaySubscriptionId, new SubscriptionCancelOptions()); } @@ -293,7 +293,7 @@ public class StripePaymentService : IPaymentService return; } - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId); if (customer == null) { return; @@ -318,7 +318,7 @@ public class StripePaymentService : IPaymentService } else { - var charges = await _stripeAdapter.ChargeListAsync(new ChargeListOptions + var charges = await _stripeAdapter.ListChargesAsync(new ChargeListOptions { Customer = subscriber.GatewayCustomerId }); @@ -327,12 +327,12 @@ public class StripePaymentService : IPaymentService { foreach (var charge in charges.Data.Where(c => c.Captured && !c.Refunded)) { - await _stripeAdapter.RefundCreateAsync(new RefundCreateOptions { Charge = charge.Id }); + await _stripeAdapter.CreateRefundAsync(new RefundCreateOptions { Charge = charge.Id }); } } } - await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId); + await _stripeAdapter.DeleteCustomerAsync(subscriber.GatewayCustomerId); } public async Task PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Invoice invoice) @@ -340,7 +340,7 @@ public class StripePaymentService : IPaymentService var customerOptions = new CustomerGetOptions(); customerOptions.AddExpand("default_source"); customerOptions.AddExpand("invoice_settings.default_payment_method"); - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); + var customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerOptions); string paymentIntentClientSecret = null; @@ -360,13 +360,13 @@ public class StripePaymentService : IPaymentService // We're going to delete this draft invoice, it can't be paid try { - await _stripeAdapter.InvoiceDeleteAsync(invoice.Id); + await _stripeAdapter.DeleteInvoiceAsync(invoice.Id); } catch { - await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, + await _stripeAdapter.FinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false }); - await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id); + await _stripeAdapter.VoidInvoiceAsync(invoice.Id); } throw new BadRequestException("No payment method is available."); @@ -379,7 +379,7 @@ public class StripePaymentService : IPaymentService { // Finalize the invoice (from Draft) w/o auto-advance so we // can attempt payment manually. - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, + invoice = await _stripeAdapter.FinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false, }); var invoicePayOptions = new InvoicePayOptions { PaymentMethod = cardPaymentMethodId, }; if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) @@ -414,7 +414,7 @@ public class StripePaymentService : IPaymentService } braintreeTransaction = transactionResult.Target; - invoice = await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new InvoiceUpdateOptions + invoice = await _stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions { Metadata = new Dictionary { @@ -428,7 +428,7 @@ public class StripePaymentService : IPaymentService try { - invoice = await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions); + invoice = await _stripeAdapter.PayInvoiceAsync(invoice.Id, invoicePayOptions); } catch (StripeException e) { @@ -438,7 +438,7 @@ public class StripePaymentService : IPaymentService // SCA required, get intent client secret var invoiceGetOptions = new InvoiceGetOptions(); invoiceGetOptions.AddExpand("confirmation_secret"); - invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions); + invoice = await _stripeAdapter.GetInvoiceAsync(invoice.Id, invoiceGetOptions); paymentIntentClientSecret = invoice?.ConfirmationSecret?.ClientSecret; } else @@ -462,7 +462,7 @@ public class StripePaymentService : IPaymentService return paymentIntentClientSecret; } - invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions()); + invoice = await _stripeAdapter.VoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions()); // HACK: Workaround for customer balance credit if (invoice.StartingBalance < 0) @@ -470,12 +470,12 @@ public class StripePaymentService : IPaymentService // Customer had a balance applied to this invoice. Since we can't fully trust Stripe to // credit it back to the customer (even though their docs claim they will), we need to // check that balance against the current customer balance and determine if it needs to be re-applied - customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); + customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerOptions); // Assumption: Customer balance should now be $0, otherwise payment would not have failed. if (customer.Balance == 0) { - await _stripeAdapter.CustomerUpdateAsync(customer.Id, + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Balance = invoice.StartingBalance }); } } @@ -506,7 +506,7 @@ public class StripePaymentService : IPaymentService throw new GatewayException("No subscription."); } - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); if (sub == null) { throw new GatewayException("Subscription was not found."); @@ -522,9 +522,9 @@ public class StripePaymentService : IPaymentService try { var canceledSub = endOfPeriod - ? await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + ? await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) - : await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions()); + : await _stripeAdapter.CancelSubscriptionAsync(sub.Id, new SubscriptionCancelOptions()); if (!canceledSub.CanceledAt.HasValue) { throw new GatewayException("Unable to cancel subscription."); @@ -551,7 +551,7 @@ public class StripePaymentService : IPaymentService throw new GatewayException("No subscription."); } - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + var sub = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); if (sub == null) { throw new GatewayException("Subscription was not found."); @@ -563,7 +563,7 @@ public class StripePaymentService : IPaymentService throw new GatewayException("Subscription is not marked for cancellation."); } - var updatedSub = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + var updatedSub = await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); if (updatedSub.CanceledAt.HasValue) { @@ -578,11 +578,11 @@ public class StripePaymentService : IPaymentService !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId); if (customerExists) { - customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + customer = await _stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId); } else { - customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions + customer = await _stripeAdapter.CreateCustomerAsync(new CustomerCreateOptions { Email = subscriber.BillingEmailAddress(), Description = subscriber.BillingName(), @@ -591,9 +591,8 @@ public class StripePaymentService : IPaymentService subscriber.GatewayCustomerId = customer.Id; } - await _stripeAdapter.CustomerUpdateAsync(customer.Id, + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Balance = customer.Balance - (long)(creditAmount * 100) }); - return !customerExists; } @@ -630,7 +629,7 @@ public class StripePaymentService : IPaymentService return subscriptionInfo; } - var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, + var subscription = await _stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); if (subscription == null) @@ -675,7 +674,7 @@ public class StripePaymentService : IPaymentService Subscription = subscriber.GatewaySubscriptionId }; - var upcomingInvoice = await _stripeAdapter.InvoiceCreatePreviewAsync(invoiceCreatePreviewOptions); + var upcomingInvoice = await _stripeAdapter.CreateInvoicePreviewAsync(invoiceCreatePreviewOptions); if (upcomingInvoice != null) { @@ -726,7 +725,7 @@ public class StripePaymentService : IPaymentService return false; } - var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(gatewayCustomerId); return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; } @@ -738,7 +737,7 @@ public class StripePaymentService : IPaymentService return (null, null); } - var openInvoices = await _stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + var openInvoices = await _stripeAdapter.SearchInvoiceAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); @@ -774,7 +773,7 @@ public class StripePaymentService : IPaymentService private PaymentMethod GetLatestCardPaymentMethod(string customerId) { - var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( + var cardPaymentMethods = _stripeAdapter.ListPaymentMethodsAutoPaging( new PaymentMethodListOptions { Customer = customerId, Type = "card" }); return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault(); } @@ -837,7 +836,7 @@ public class StripePaymentService : IPaymentService Customer customer = null; try { - customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId, options); + customer = await _stripeAdapter.GetCustomerAsync(gatewayCustomerId, options); } catch (StripeException) { @@ -870,21 +869,21 @@ public class StripePaymentService : IPaymentService try { - var paidInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var paidInvoicesTask = _stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "paid" }); - var openInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var openInvoicesTask = _stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "open" }); - var uncollectibleInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions + var uncollectibleInvoicesTask = _stripeAdapter.ListInvoicesAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, diff --git a/src/Core/Services/Implementations/StripeSyncService.cs b/src/Core/Billing/Services/Implementations/StripeSyncService.cs similarity index 68% rename from src/Core/Services/Implementations/StripeSyncService.cs rename to src/Core/Billing/Services/Implementations/StripeSyncService.cs index b2700e65d1..31dd89d72d 100644 --- a/src/Core/Services/Implementations/StripeSyncService.cs +++ b/src/Core/Billing/Services/Implementations/StripeSyncService.cs @@ -1,6 +1,6 @@ using Bit.Core.Exceptions; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services.Implementations; public class StripeSyncService : IStripeSyncService { @@ -11,7 +11,7 @@ public class StripeSyncService : IStripeSyncService _stripeAdapter = stripeAdapter; } - public async Task UpdateCustomerEmailAddress(string gatewayCustomerId, string emailAddress) + public async Task UpdateCustomerEmailAddressAsync(string gatewayCustomerId, string emailAddress) { if (string.IsNullOrWhiteSpace(gatewayCustomerId)) { @@ -23,9 +23,9 @@ public class StripeSyncService : IStripeSyncService throw new InvalidEmailException(); } - var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + var customer = await _stripeAdapter.GetCustomerAsync(gatewayCustomerId); - await _stripeAdapter.CustomerUpdateAsync(customer.Id, + await _stripeAdapter.UpdateCustomerAsync(customer.Id, new Stripe.CustomerUpdateOptions { Email = emailAddress }); } } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 4b2ea26294..7acbe20014 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -15,7 +15,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; @@ -78,7 +77,7 @@ public class SubscriberService( { if (subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId")) { - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { Metadata = metadata }); @@ -97,7 +96,7 @@ public class SubscriberService( options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; } - await stripeAdapter.SubscriptionCancelAsync(subscription.Id, options); + await stripeAdapter.CancelSubscriptionAsync(subscription.Id, options); } else { @@ -116,7 +115,7 @@ public class SubscriberService( options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason; } - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, options); + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); } } @@ -227,7 +226,7 @@ public class SubscriberService( _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) }; - var customer = await stripeAdapter.CustomerCreateAsync(options); + var customer = await stripeAdapter.CreateCustomerAsync(options); switch (subscriber) { @@ -270,7 +269,7 @@ public class SubscriberService( try { - var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); + var customer = await stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerGetOptions); if (customer != null) { @@ -306,7 +305,7 @@ public class SubscriberService( try { - var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); + var customer = await stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId, customerGetOptions); if (customer != null) { @@ -357,7 +356,7 @@ public class SubscriberService( try { - var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); if (subscription != null) { @@ -393,7 +392,7 @@ public class SubscriberService( try { - var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); + var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); if (subscription != null) { @@ -487,23 +486,23 @@ public class SubscriberService( switch (source) { case BankAccount: - await stripeAdapter.BankAccountDeleteAsync(stripeCustomer.Id, source.Id); + await stripeAdapter.DeleteBankAccountAsync(stripeCustomer.Id, source.Id); break; case Card: - await stripeAdapter.CardDeleteAsync(stripeCustomer.Id, source.Id); + await stripeAdapter.DeleteCardAsync(stripeCustomer.Id, source.Id); break; } } } - var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new PaymentMethodListOptions + var paymentMethods = stripeAdapter.ListPaymentMethodsAutoPagingAsync(new PaymentMethodListOptions { Customer = stripeCustomer.Id }); await foreach (var paymentMethod in paymentMethods) { - await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id); + await stripeAdapter.DetachPaymentMethodAsync(paymentMethod.Id); } } } @@ -532,7 +531,7 @@ public class SubscriberService( { case PaymentMethodType.BankAccount: { - var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.SetupIntentList(new SetupIntentListOptions + var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions { PaymentMethod = token }); @@ -569,7 +568,7 @@ public class SubscriberService( await RemoveStripePaymentMethodsAsync(customer); // Attach the incoming payment method. - await stripeAdapter.PaymentMethodAttachAsync(token, + await stripeAdapter.AttachPaymentMethodAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); var metadata = customer.Metadata; @@ -581,7 +580,7 @@ public class SubscriberService( } // Set the customer's default payment method in Stripe and remove their Braintree customer ID. - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { @@ -644,7 +643,7 @@ public class SubscriberService( Expand = ["subscriptions", "tax", "tax_ids"] }); - customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + customer = await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Address = new AddressOptions { @@ -662,7 +661,7 @@ public class SubscriberService( if (taxId != null) { - await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); + await stripeAdapter.DeleteTaxIdAsync(customer.Id, taxId.Id); } if (!string.IsNullOrWhiteSpace(taxInformation.TaxId)) @@ -685,12 +684,12 @@ public class SubscriberService( try { - await stripeAdapter.TaxIdCreateAsync(customer.Id, + await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) { - await stripeAdapter.TaxIdCreateAsync(customer.Id, + await stripeAdapter.CreateTaxIdAsync(customer.Id, new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInformation.TaxId}" }); } } @@ -736,7 +735,7 @@ public class SubscriberService( Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not TaxExempt.Reverse }: - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); break; case @@ -744,14 +743,14 @@ public class SubscriberService( Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: TaxExempt.Reverse }: - await stripeAdapter.CustomerUpdateAsync(customer.Id, + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { TaxExempt = TaxExempt.None }); break; } if (!subscription.AutomaticTax.Enabled) { - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -771,7 +770,7 @@ public class SubscriberService( if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled) { - await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, + await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } @@ -790,7 +789,7 @@ public class SubscriberService( } try { - await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + await stripeAdapter.GetCustomerAsync(subscriber.GatewayCustomerId); return true; } catch (StripeException e) when (e.StripeError.Code == "resource_missing") @@ -809,7 +808,7 @@ public class SubscriberService( } try { - await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId); return true; } catch (StripeException e) when (e.StripeError.Code == "resource_missing") @@ -828,7 +827,7 @@ public class SubscriberService( metadata[BraintreeCustomerIdKey] = braintreeCustomerId; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); @@ -868,7 +867,7 @@ public class SubscriberService( return null; } - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + var setupIntent = await stripeAdapter.GetSetupIntentAsync(setupIntentId, new SetupIntentGetOptions { Expand = ["payment_method"] }); @@ -886,7 +885,7 @@ public class SubscriberService( metadata[BraintreeCustomerIdOldKey] = value; metadata[BraintreeCustomerIdKey] = null; - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + await stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); @@ -903,18 +902,18 @@ public class SubscriberService( switch (source) { case BankAccount: - await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); + await stripeAdapter.DeleteBankAccountAsync(customer.Id, source.Id); break; case Card: - await stripeAdapter.CardDeleteAsync(customer.Id, source.Id); + await stripeAdapter.DeleteCardAsync(customer.Id, source.Id); break; } } } - var paymentMethods = await stripeAdapter.CustomerListPaymentMethods(customer.Id); + var paymentMethods = await stripeAdapter.ListCustomerPaymentMethodsAsync(customer.Id); - await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.PaymentMethodDetachAsync(pm.Id))); + await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.DetachPaymentMethodAsync(pm.Id))); } private async Task ReplaceBraintreePaymentMethodAsync( diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index ee60597601..7f7be9d1eb 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -7,7 +7,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Repositories; -using Bit.Core.Services; using OneOf.Types; using Stripe; @@ -53,7 +52,7 @@ public class RestartSubscriptionCommand( TrialPeriodDays = 0 }; - var subscription = await stripeAdapter.SubscriptionCreateAsync(options); + var subscription = await stripeAdapter.CreateSubscriptionAsync(options); await EnableAsync(subscriber, subscription); return new None(); } diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 2ee6b75664..ec5978988c 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -2,8 +2,8 @@ #nullable disable using Bit.Core.Billing.Models; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Services; using Stripe; namespace Bit.Core.Billing; @@ -22,7 +22,7 @@ public static class Utilities return null; } - var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + var openInvoices = await stripeAdapter.SearchInvoiceAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs index 0aebc3fc3b..6d60f05b2a 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; @@ -13,9 +13,9 @@ public class SetUpSponsorshipCommand : ISetUpSponsorshipCommand { private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; - public SetUpSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationRepository organizationRepository, IPaymentService paymentService) + public SetUpSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationRepository organizationRepository, IStripePaymentService paymentService) { _organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationRepository = organizationRepository; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs index a26d553570..4b983317c9 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -14,14 +15,14 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSponsorshipCommand { - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IMailService _mailService; private readonly ILogger _logger; public ValidateSponsorshipCommand( IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationRepository organizationRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IMailService mailService, ILogger logger) : base(organizationSponsorshipRepository, organizationRepository) { diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs index a0ce7c03b9..25b84fe989 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; @@ -12,13 +13,13 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscriptionCommand { - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IOrganizationService _organizationService; private readonly IProviderRepository _providerRepository; private readonly IPricingClient _pricingClient; public AddSecretsManagerSubscriptionCommand( - IPaymentService paymentService, + IStripePaymentService paymentService, IOrganizationService organizationService, IProviderRepository providerRepository, IPricingClient pricingClient) diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index d4e1b3cd8d..baf2616a53 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -18,7 +19,7 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubscriptionCommand { private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IMailService _mailService; private readonly ILogger _logger; private readonly IServiceAccountRepository _serviceAccountRepository; @@ -29,7 +30,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs public UpdateSecretsManagerSubscriptionCommand( IOrganizationUserRepository organizationUserRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IMailService mailService, ILogger logger, IServiceAccountRepository serviceAccountRepository, diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index b704cb0460..092ee0f46e 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -26,7 +27,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; @@ -41,7 +42,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand IOrganizationUserRepository organizationUserRepository, ICollectionRepository collectionRepository, IGroupRepository groupRepository, - IPaymentService paymentService, + IStripePaymentService paymentService, IPolicyRepository policyRepository, ISsoConfigRepository ssoConfigRepository, IOrganizationConnectionRepository organizationConnectionRepository, diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs deleted file mode 100644 index 6b2c3c299e..0000000000 --- a/src/Core/Services/IStripeAdapter.cs +++ /dev/null @@ -1,54 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Models.BitStripe; -using Stripe; -using Stripe.Tax; - -namespace Bit.Core.Services; - -public interface IStripeAdapter -{ - Task CustomerCreateAsync(CustomerCreateOptions customerCreateOptions); - Task CustomerDeleteDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null); - Task CustomerGetAsync(string id, CustomerGetOptions options = null); - Task CustomerUpdateAsync(string id, CustomerUpdateOptions options = null); - Task CustomerDeleteAsync(string id); - Task> CustomerListPaymentMethods(string id, CustomerPaymentMethodListOptions options = null); - Task CustomerBalanceTransactionCreate(string customerId, - CustomerBalanceTransactionCreateOptions options); - Task SubscriptionCreateAsync(SubscriptionCreateOptions subscriptionCreateOptions); - Task SubscriptionGetAsync(string id, SubscriptionGetOptions options = null); - Task SubscriptionUpdateAsync(string id, SubscriptionUpdateOptions options = null); - Task SubscriptionCancelAsync(string Id, SubscriptionCancelOptions options = null); - Task InvoiceGetAsync(string id, InvoiceGetOptions options); - Task> InvoiceListAsync(StripeInvoiceListOptions options); - Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options); - Task> InvoiceSearchAsync(InvoiceSearchOptions options); - Task InvoiceUpdateAsync(string id, InvoiceUpdateOptions options); - Task InvoiceFinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options); - Task InvoiceSendInvoiceAsync(string id, InvoiceSendOptions options); - Task InvoicePayAsync(string id, InvoicePayOptions options = null); - Task InvoiceDeleteAsync(string id, InvoiceDeleteOptions options = null); - Task InvoiceVoidInvoiceAsync(string id, InvoiceVoidOptions options = null); - IEnumerable PaymentMethodListAutoPaging(PaymentMethodListOptions options); - IAsyncEnumerable PaymentMethodListAutoPagingAsync(PaymentMethodListOptions options); - Task PaymentMethodAttachAsync(string id, PaymentMethodAttachOptions options = null); - Task PaymentMethodDetachAsync(string id, PaymentMethodDetachOptions options = null); - Task TaxIdCreateAsync(string id, TaxIdCreateOptions options); - Task TaxIdDeleteAsync(string customerId, string taxIdId, TaxIdDeleteOptions options = null); - Task> TaxRegistrationsListAsync(RegistrationListOptions options = null); - Task> ChargeListAsync(ChargeListOptions options); - Task RefundCreateAsync(RefundCreateOptions options); - Task CardDeleteAsync(string customerId, string cardId, CardDeleteOptions options = null); - Task BankAccountCreateAsync(string customerId, BankAccountCreateOptions options = null); - Task BankAccountDeleteAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null); - Task> PriceListAsync(PriceListOptions options = null); - Task SetupIntentCreate(SetupIntentCreateOptions options); - Task> SetupIntentList(SetupIntentListOptions options); - Task SetupIntentCancel(string id, SetupIntentCancelOptions options = null); - Task SetupIntentGet(string id, SetupIntentGetOptions options = null); - Task SetupIntentVerifyMicroDeposit(string id, SetupIntentVerifyMicrodepositsOptions options); - Task> TestClockListAsync(); - Task PriceGetAsync(string id, PriceGetOptions options = null); -} diff --git a/src/Core/Services/IStripeSyncService.cs b/src/Core/Services/IStripeSyncService.cs deleted file mode 100644 index 655998805e..0000000000 --- a/src/Core/Services/IStripeSyncService.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Services; - -public interface IStripeSyncService -{ - Task UpdateCustomerEmailAddress(string gatewayCustomerId, string emailAddress); -} diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs deleted file mode 100644 index 3d1663f021..0000000000 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ /dev/null @@ -1,284 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Models.BitStripe; -using Stripe; -using Stripe.Tax; - -namespace Bit.Core.Services; - -public class StripeAdapter : IStripeAdapter -{ - private readonly CustomerService _customerService; - private readonly SubscriptionService _subscriptionService; - private readonly InvoiceService _invoiceService; - private readonly PaymentMethodService _paymentMethodService; - private readonly TaxIdService _taxIdService; - private readonly ChargeService _chargeService; - private readonly RefundService _refundService; - private readonly CardService _cardService; - private readonly BankAccountService _bankAccountService; - private readonly PlanService _planService; - private readonly PriceService _priceService; - private readonly SetupIntentService _setupIntentService; - private readonly Stripe.TestHelpers.TestClockService _testClockService; - private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; - private readonly Stripe.Tax.RegistrationService _taxRegistrationService; - private readonly CalculationService _calculationService; - - public StripeAdapter() - { - _customerService = new CustomerService(); - _subscriptionService = new SubscriptionService(); - _invoiceService = new InvoiceService(); - _paymentMethodService = new PaymentMethodService(); - _taxIdService = new TaxIdService(); - _chargeService = new ChargeService(); - _refundService = new RefundService(); - _cardService = new CardService(); - _bankAccountService = new BankAccountService(); - _priceService = new PriceService(); - _planService = new PlanService(); - _setupIntentService = new SetupIntentService(); - _testClockService = new Stripe.TestHelpers.TestClockService(); - _customerBalanceTransactionService = new CustomerBalanceTransactionService(); - _taxRegistrationService = new Stripe.Tax.RegistrationService(); - _calculationService = new CalculationService(); - } - - public Task CustomerCreateAsync(CustomerCreateOptions options) - { - return _customerService.CreateAsync(options); - } - - public Task CustomerDeleteDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null) => - _customerService.DeleteDiscountAsync(customerId, options); - - public Task CustomerGetAsync(string id, CustomerGetOptions options = null) - { - return _customerService.GetAsync(id, options); - } - - public Task CustomerUpdateAsync(string id, CustomerUpdateOptions options = null) - { - return _customerService.UpdateAsync(id, options); - } - - public Task CustomerDeleteAsync(string id) - { - return _customerService.DeleteAsync(id); - } - - public async Task> CustomerListPaymentMethods(string id, - CustomerPaymentMethodListOptions options = null) - { - var paymentMethods = await _customerService.ListPaymentMethodsAsync(id, options); - return paymentMethods.Data; - } - - public async Task CustomerBalanceTransactionCreate(string customerId, - CustomerBalanceTransactionCreateOptions options) - => await _customerBalanceTransactionService.CreateAsync(customerId, options); - - public Task SubscriptionCreateAsync(SubscriptionCreateOptions options) - { - return _subscriptionService.CreateAsync(options); - } - - public Task SubscriptionGetAsync(string id, SubscriptionGetOptions options = null) - { - return _subscriptionService.GetAsync(id, options); - } - - public async Task ProviderSubscriptionGetAsync( - string id, - Guid providerId, - SubscriptionGetOptions options = null) - { - var subscription = await _subscriptionService.GetAsync(id, options); - if (subscription.Metadata.TryGetValue("providerId", out var value) && value == providerId.ToString()) - { - return subscription; - } - - throw new InvalidOperationException("Subscription does not belong to the provider."); - } - - public Task SubscriptionUpdateAsync(string id, - SubscriptionUpdateOptions options = null) - { - return _subscriptionService.UpdateAsync(id, options); - } - - public Task SubscriptionCancelAsync(string Id, SubscriptionCancelOptions options = null) - { - return _subscriptionService.CancelAsync(Id, options); - } - - public Task InvoiceGetAsync(string id, InvoiceGetOptions options) - { - return _invoiceService.GetAsync(id, options); - } - - public async Task> InvoiceListAsync(StripeInvoiceListOptions options) - { - if (!options.SelectAll) - { - return (await _invoiceService.ListAsync(options.ToInvoiceListOptions())).Data; - } - - options.Limit = 100; - - var invoices = new List(); - - await foreach (var invoice in _invoiceService.ListAutoPagingAsync(options.ToInvoiceListOptions())) - { - invoices.Add(invoice); - } - - return invoices; - } - - public Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options) - { - return _invoiceService.CreatePreviewAsync(options); - } - - public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) - => (await _invoiceService.SearchAsync(options)).Data; - - public Task InvoiceUpdateAsync(string id, InvoiceUpdateOptions options) - { - return _invoiceService.UpdateAsync(id, options); - } - - public Task InvoiceFinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options) - { - return _invoiceService.FinalizeInvoiceAsync(id, options); - } - - public Task InvoiceSendInvoiceAsync(string id, InvoiceSendOptions options) - { - return _invoiceService.SendInvoiceAsync(id, options); - } - - public Task InvoicePayAsync(string id, InvoicePayOptions options = null) - { - return _invoiceService.PayAsync(id, options); - } - - public Task InvoiceDeleteAsync(string id, InvoiceDeleteOptions options = null) - { - return _invoiceService.DeleteAsync(id, options); - } - - public Task InvoiceVoidInvoiceAsync(string id, InvoiceVoidOptions options = null) - { - return _invoiceService.VoidInvoiceAsync(id, options); - } - - public IEnumerable PaymentMethodListAutoPaging(PaymentMethodListOptions options) - { - return _paymentMethodService.ListAutoPaging(options); - } - - public IAsyncEnumerable PaymentMethodListAutoPagingAsync(PaymentMethodListOptions options) - => _paymentMethodService.ListAutoPagingAsync(options); - - public Task PaymentMethodAttachAsync(string id, PaymentMethodAttachOptions options = null) - { - return _paymentMethodService.AttachAsync(id, options); - } - - public Task PaymentMethodDetachAsync(string id, PaymentMethodDetachOptions options = null) - { - return _paymentMethodService.DetachAsync(id, options); - } - - public Task PlanGetAsync(string id, PlanGetOptions options = null) - { - return _planService.GetAsync(id, options); - } - - public Task TaxIdCreateAsync(string id, TaxIdCreateOptions options) - { - return _taxIdService.CreateAsync(id, options); - } - - public Task TaxIdDeleteAsync(string customerId, string taxIdId, - TaxIdDeleteOptions options = null) - { - return _taxIdService.DeleteAsync(customerId, taxIdId); - } - - public Task> TaxRegistrationsListAsync(RegistrationListOptions options = null) - { - return _taxRegistrationService.ListAsync(options); - } - - public Task> ChargeListAsync(ChargeListOptions options) - { - return _chargeService.ListAsync(options); - } - - public Task RefundCreateAsync(RefundCreateOptions options) - { - return _refundService.CreateAsync(options); - } - - public Task CardDeleteAsync(string customerId, string cardId, CardDeleteOptions options = null) - { - return _cardService.DeleteAsync(customerId, cardId, options); - } - - public Task BankAccountCreateAsync(string customerId, BankAccountCreateOptions options = null) - { - return _bankAccountService.CreateAsync(customerId, options); - } - - public Task BankAccountDeleteAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null) - { - return _bankAccountService.DeleteAsync(customerId, bankAccount, options); - } - - public async Task> PriceListAsync(PriceListOptions options = null) - { - return await _priceService.ListAsync(options); - } - - public Task SetupIntentCreate(SetupIntentCreateOptions options) - => _setupIntentService.CreateAsync(options); - - public async Task> SetupIntentList(SetupIntentListOptions options) - { - var setupIntents = await _setupIntentService.ListAsync(options); - - return setupIntents.Data; - } - - public Task SetupIntentCancel(string id, SetupIntentCancelOptions options = null) - => _setupIntentService.CancelAsync(id, options); - - public Task SetupIntentGet(string id, SetupIntentGetOptions options = null) - => _setupIntentService.GetAsync(id, options); - - public Task SetupIntentVerifyMicroDeposit(string id, SetupIntentVerifyMicrodepositsOptions options) - => _setupIntentService.VerifyMicrodepositsAsync(id, options); - - public async Task> TestClockListAsync() - { - var items = new List(); - var options = new Stripe.TestHelpers.TestClockListOptions() - { - Limit = 100 - }; - await foreach (var i in _testClockService.ListAutoPagingAsync(options)) - { - items.Add(i); - } - return items; - } - - public Task PriceGetAsync(string id, PriceGetOptions options = null) - => _priceService.GetAsync(id, options); -} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 2d2a9f0ae7..fbc382cb08 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -57,7 +57,7 @@ public class UserService : UserManager, IUserService private readonly ILicensingService _licenseService; private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly IFido2 _fido2; @@ -93,7 +93,7 @@ public class UserService : UserManager, IUserService ILicensingService licenseService, IEventService eventService, IApplicationCacheService applicationCacheService, - IPaymentService paymentService, + IStripePaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, IFido2 fido2, @@ -534,7 +534,7 @@ public class UserService : UserManager, IUserService try { - await _stripeSyncService.UpdateCustomerEmailAddress(user.GatewayCustomerId, + await _stripeSyncService.UpdateCustomerEmailAddressAsync(user.GatewayCustomerId, user.BillingEmailAddress()); } catch (Exception ex) @@ -867,7 +867,7 @@ public class UserService : UserManager, IUserService } string paymentIntentClientSecret = null; - IPaymentService paymentService = null; + IStripePaymentService paymentService = null; if (_globalSettings.SelfHosted) { if (license == null || !_licenseService.VerifyLicense(license)) diff --git a/src/Core/Utilities/BillingHelpers.cs b/src/Core/Utilities/BillingHelpers.cs index 2c1dfcbbbd..ef0fdf010b 100644 --- a/src/Core/Utilities/BillingHelpers.cs +++ b/src/Core/Utilities/BillingHelpers.cs @@ -1,12 +1,12 @@ -using Bit.Core.Entities; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.Services; namespace Bit.Core.Utilities; public static class BillingHelpers { - internal static async Task AdjustStorageAsync(IPaymentService paymentService, IStorableSubscriber storableSubscriber, + internal static async Task AdjustStorageAsync(IStripePaymentService paymentService, IStorableSubscriber storableSubscriber, short storageAdjustmentGb, string storagePlanId, short baseStorageGb) { if (storableSubscriber == null) diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 8dd629e63a..91047d98bc 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -238,7 +238,7 @@ public static class ServiceCollectionExtensions PrivateKey = globalSettings.Braintree.PrivateKey }; }); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); // Legacy mailer service diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs index 0309264096..16b9b26436 100644 --- a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -28,7 +28,7 @@ public class AccountsControllerTests : IDisposable private readonly IUserService _userService; private readonly IFeatureService _featureService; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ILicensingService _licensingService; @@ -39,7 +39,7 @@ public class AccountsControllerTests : IDisposable { _userService = Substitute.For(); _featureService = Substitute.For(); - _paymentService = Substitute.For(); + _paymentService = Substitute.For(); _twoFactorIsEnabledQuery = Substitute.For(); _userAccountKeysQuery = Substitute.For(); _licensingService = Substitute.For(); diff --git a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs index d79bfde893..ee0bdc61e4 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs @@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http.HttpResults; @@ -103,7 +103,7 @@ public class OrganizationBillingControllerTests // Manually create a BillingHistoryInfo object to avoid requiring AutoFixture to create HttpResponseHeaders var billingInfo = new BillingHistoryInfo(); - sutProvider.GetDependency().GetBillingHistoryAsync(organization).Returns(billingInfo); + sutProvider.GetDependency().GetBillingHistoryAsync(organization).Returns(billingInfo); // Act var result = await sutProvider.Sut.GetHistoryAsync(organizationId); diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs index a776bbea22..9a3f57c3dc 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -37,7 +37,7 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationService _organizationService; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPaymentService _paymentService; + private readonly IStripePaymentService _paymentService; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IUserService _userService; private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; @@ -59,7 +59,7 @@ public class OrganizationsControllerTests : IDisposable _organizationRepository = Substitute.For(); _organizationService = Substitute.For(); _organizationUserRepository = Substitute.For(); - _paymentService = Substitute.For(); + _paymentService = Substitute.For(); Substitute.For(); _ssoConfigRepository = Substitute.For(); Substitute.For(); diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index b7349c09d9..652e82c801 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -10,10 +10,10 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Models.Api; using Bit.Core.Models.BitStripe; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -121,7 +121,7 @@ public class ProviderBillingControllerTests } }; - sutProvider.GetDependency().InvoiceListAsync(Arg.Is( + sutProvider.GetDependency().ListInvoicesAsync(Arg.Is( options => options.Customer == provider.GatewayCustomerId)).Returns(invoices); @@ -301,7 +301,7 @@ public class ProviderBillingControllerTests Status = "unpaid" }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is( + stripeAdapter.GetSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Expand.Contains("customer.tax_ids") && options.Expand.Contains("discounts") && @@ -318,7 +318,7 @@ public class ProviderBillingControllerTests Attempted = true }; - stripeAdapter.InvoiceSearchAsync(Arg.Is( + stripeAdapter.SearchInvoiceAsync(Arg.Is( options => options.Query == $"subscription:'{subscription.Id}' status:'open'")) .Returns([overdueInvoice]); @@ -351,7 +351,7 @@ public class ProviderBillingControllerTests var plan = MockPlans.Get(providerPlan.PlanType); sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(plan); var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); - sutProvider.GetDependency().PriceGetAsync(priceId) + sutProvider.GetDependency().GetPriceAsync(priceId) .Returns(new Price { UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100 @@ -459,13 +459,13 @@ public class ProviderBillingControllerTests Status = "active" }; - stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is( + stripeAdapter.GetSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Expand.Contains("customer.tax_ids") && options.Expand.Contains("discounts") && options.Expand.Contains("test_clock"))).Returns(subscription); - stripeAdapter.InvoiceSearchAsync(Arg.Is( + stripeAdapter.SearchInvoiceAsync(Arg.Is( options => options.Query == $"subscription:'{subscription.Id}' status:'open'")) .Returns([]); @@ -498,7 +498,7 @@ public class ProviderBillingControllerTests var plan = MockPlans.Get(providerPlan.PlanType); sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(plan); var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); - sutProvider.GetDependency().PriceGetAsync(priceId) + sutProvider.GetDependency().GetPriceAsync(priceId) .Returns(new Price { UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100 diff --git a/test/Billing.Test/Controllers/BitPayControllerTests.cs b/test/Billing.Test/Controllers/BitPayControllerTests.cs index d2d1c5b571..0118009cb7 100644 --- a/test/Billing.Test/Controllers/BitPayControllerTests.cs +++ b/test/Billing.Test/Controllers/BitPayControllerTests.cs @@ -31,7 +31,7 @@ public class BitPayControllerTests private readonly IUserRepository _userRepository = Substitute.For(); private readonly IProviderRepository _providerRepository = Substitute.For(); private readonly IMailService _mailService = Substitute.For(); - private readonly IPaymentService _paymentService = Substitute.For(); + private readonly IStripePaymentService _paymentService = Substitute.For(); private readonly IPremiumUserBillingService _premiumUserBillingService = Substitute.For(); diff --git a/test/Billing.Test/Controllers/PayPalControllerTests.cs b/test/Billing.Test/Controllers/PayPalControllerTests.cs index f52a304bb6..da995b6188 100644 --- a/test/Billing.Test/Controllers/PayPalControllerTests.cs +++ b/test/Billing.Test/Controllers/PayPalControllerTests.cs @@ -28,7 +28,7 @@ public class PayPalControllerTests(ITestOutputHelper testOutputHelper) private readonly IOptions _billingSettings = Substitute.For>(); private readonly IMailService _mailService = Substitute.For(); private readonly IOrganizationRepository _organizationRepository = Substitute.For(); - private readonly IPaymentService _paymentService = Substitute.For(); + private readonly IStripePaymentService _paymentService = Substitute.For(); private readonly ITransactionRepository _transactionRepository = Substitute.For(); private readonly IUserRepository _userRepository = Substitute.For(); private readonly IProviderRepository _providerRepository = Substitute.For(); diff --git a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs index e9f0d9d0ed..a7aefe3163 100644 --- a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs +++ b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs @@ -4,8 +4,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Services; using Bit.Core.Repositories; -using Bit.Core.Services; using NSubstitute; using Stripe; using Xunit; @@ -61,7 +61,7 @@ public class SetupIntentSucceededHandlerTests // Assert await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any()); - await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync( Arg.Any(), Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); @@ -86,7 +86,7 @@ public class SetupIntentSucceededHandlerTests await _handler.HandleAsync(_mockEvent); // Assert - await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync( Arg.Any(), Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); @@ -116,7 +116,7 @@ public class SetupIntentSucceededHandlerTests await _handler.HandleAsync(_mockEvent); // Assert - await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + await _stripeAdapter.Received(1).AttachPaymentMethodAsync( "pm_test", Arg.Is(o => o.Customer == organization.GatewayCustomerId)); @@ -151,7 +151,7 @@ public class SetupIntentSucceededHandlerTests await _handler.HandleAsync(_mockEvent); // Assert - await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + await _stripeAdapter.Received(1).AttachPaymentMethodAsync( "pm_test", Arg.Is(o => o.Customer == provider.GatewayCustomerId)); @@ -183,7 +183,7 @@ public class SetupIntentSucceededHandlerTests await _handler.HandleAsync(_mockEvent); // Assert - await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync( Arg.Any(), Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); @@ -216,7 +216,7 @@ public class SetupIntentSucceededHandlerTests await _handler.HandleAsync(_mockEvent); // Assert - await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + await _stripeAdapter.DidNotReceiveWithAnyArgs().AttachPaymentMethodAsync( Arg.Any(), Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs index 933bcbc3a1..efcd57b6ad 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.Import; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -57,7 +58,7 @@ public class ImportOrganizationUsersAndGroupsCommandTests var organizationUserRepository = sutProvider.GetDependency(); SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository); - sutProvider.GetDependency().HasSecretsManagerStandalone(org).Returns(true); + sutProvider.GetDependency().HasSecretsManagerStandalone(org).Returns(true); sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id).Returns(existingUsers); sutProvider.GetDependency().GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns( new OrganizationSeatCounts diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs index 04ef3961ca..e26d9ce978 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUsersValidatorTests.cs @@ -3,11 +3,11 @@ using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks.Plans; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -50,7 +50,7 @@ public class InviteOrganizationUsersValidatorTests OccupiedSmSeats = 9 }; - sutProvider.GetDependency() + sutProvider.GetDependency() .HasSecretsManagerStandalone(request.InviteOrganization) .Returns(true); @@ -96,7 +96,7 @@ public class InviteOrganizationUsersValidatorTests OccupiedSmSeats = 9 }; - sutProvider.GetDependency() + sutProvider.GetDependency() .HasSecretsManagerStandalone(request.InviteOrganization) .Returns(true); @@ -140,7 +140,7 @@ public class InviteOrganizationUsersValidatorTests OccupiedSmSeats = 4 }; - sutProvider.GetDependency() + sutProvider.GetDependency() .HasSecretsManagerStandalone(request.InviteOrganization) .Returns(true); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs index 55e5698ad4..69f69b1d02 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/ResellerClientOrganizationSignUpCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -172,7 +173,7 @@ public class ResellerClientOrganizationSignUpCommandTests private static async Task AssertCleanupIsPerformed(SutProvider sutProvider) { - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .CancelAndRecoverChargesAsync(Arg.Any()); await sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs index f9fc086873..47872cc6ab 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs @@ -2,9 +2,9 @@ using Bit.Core.AdminConsole.Models.Data.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services; using Bit.Core.Models.StaticStore; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks.Plans; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +28,7 @@ public class UpdateOrganizationSubscriptionCommandTests // Act await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -53,7 +53,7 @@ public class UpdateOrganizationSubscriptionCommandTests // Act await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .AdjustSeatsAsync( Arg.Is(x => x.Id == organization.Id), @@ -81,7 +81,7 @@ public class UpdateOrganizationSubscriptionCommandTests OrganizationSubscriptionUpdate[] subscriptionsToUpdate = [new() { Organization = organization, Plan = new Enterprise2023Plan(true) }]; - sutProvider.GetDependency() + sutProvider.GetDependency() .AdjustSeatsAsync( Arg.Is(x => x.Id == organization.Id), Arg.Is(x => x.Type == organization.PlanType), @@ -115,7 +115,7 @@ public class UpdateOrganizationSubscriptionCommandTests new() { Organization = failedOrganization, Plan = new Enterprise2023Plan(true) } ]; - sutProvider.GetDependency() + sutProvider.GetDependency() .AdjustSeatsAsync( Arg.Is(x => x.Id == failedOrganization.Id), Arg.Is(x => x.Type == failedOrganization.PlanType), @@ -124,7 +124,7 @@ public class UpdateOrganizationSubscriptionCommandTests // Act await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .AdjustSeatsAsync( Arg.Is(x => x.Id == successfulOrganization.Id), diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 821ce78074..43a33cda31 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -1142,7 +1143,7 @@ public class OrganizationServiceTests .GetByIdentifierAsync(Arg.Is(id => id == organization.Identifier)); await stripeAdapter .Received(1) - .CustomerUpdateAsync( + .UpdateCustomerAsync( Arg.Is(id => id == organization.GatewayCustomerId), Arg.Is(options => options.Email == requestOptionsReturned.Email && options.Description == requestOptionsReturned.Description @@ -1182,7 +1183,7 @@ public class OrganizationServiceTests .GetByIdentifierAsync(Arg.Is(id => id == organization.Identifier)); await stripeAdapter .DidNotReceiveWithAnyArgs() - .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateCustomerAsync(Arg.Any(), Arg.Any()); await organizationRepository .Received(1) .ReplaceAsync(Arg.Is(org => org == organization)); diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index ef2b1512c9..2f278dcd20 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -4,7 +4,7 @@ using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Services; +using Bit.Core.Billing.Services; using Bit.Core.Test.Billing.Mocks.Plans; using Microsoft.Extensions.Logging; using NSubstitute; @@ -58,7 +58,7 @@ public class PreviewOrganizationTaxCommandTests Total = 5500 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(purchase, billingAddress); @@ -68,7 +68,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(55.00m, total); // Verify the correct Stripe API call for sponsored subscription - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -116,7 +116,7 @@ public class PreviewOrganizationTaxCommandTests Total = 8250 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(purchase, billingAddress); @@ -126,7 +126,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(82.50m, total); // Verify the correct Stripe API call for standalone secrets manager - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "CA" && @@ -179,7 +179,7 @@ public class PreviewOrganizationTaxCommandTests Total = 12200 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(purchase, billingAddress); @@ -189,7 +189,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(122.00m, total); // Verify the correct Stripe API call for comprehensive purchase with storage and service accounts - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "GB" && @@ -240,7 +240,7 @@ public class PreviewOrganizationTaxCommandTests Total = 3300 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(purchase, billingAddress); @@ -250,7 +250,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(33.00m, total); // Verify the correct Stripe API call for Families tier (non-seat-based plan) - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -292,7 +292,7 @@ public class PreviewOrganizationTaxCommandTests Total = 2700 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(purchase, billingAddress); @@ -302,7 +302,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(27.00m, total); // Verify the correct Stripe API call for business use in non-US country (tax exempt reverse) - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "DE" && @@ -345,7 +345,7 @@ public class PreviewOrganizationTaxCommandTests Total = 12100 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(purchase, billingAddress); @@ -355,7 +355,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(121.00m, total); // Verify the correct Stripe API call for Spanish NIF that adds both Spanish NIF and EU VAT tax IDs - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "ES" && @@ -405,7 +405,7 @@ public class PreviewOrganizationTaxCommandTests Total = 1320 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -415,7 +415,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(13.20m, total); // Verify the correct Stripe API call for free organization upgrade to Teams - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -458,7 +458,7 @@ public class PreviewOrganizationTaxCommandTests Total = 4400 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -468,7 +468,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(44.00m, total); // Verify the correct Stripe API call for free organization upgrade to Families (no SM for Families) - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "CA" && @@ -522,7 +522,7 @@ public class PreviewOrganizationTaxCommandTests Customer = new Customer { Discount = null } }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -534,7 +534,7 @@ public class PreviewOrganizationTaxCommandTests Total = 9900 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -543,7 +543,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(9.00m, tax); Assert.Equal(99.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -597,7 +597,7 @@ public class PreviewOrganizationTaxCommandTests Customer = new Customer { Discount = null } }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -609,7 +609,7 @@ public class PreviewOrganizationTaxCommandTests Total = 13200 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -618,7 +618,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(12.00m, tax); Assert.Equal(132.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -661,7 +661,7 @@ public class PreviewOrganizationTaxCommandTests Total = 8800 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -671,7 +671,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(88.00m, total); // Verify the correct Stripe API call for free organization with SM to Enterprise - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "GB" && @@ -730,7 +730,7 @@ public class PreviewOrganizationTaxCommandTests Customer = new Customer { Discount = null } }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -738,7 +738,7 @@ public class PreviewOrganizationTaxCommandTests Total = 16500 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -748,7 +748,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(165.00m, total); // Verify the correct Stripe API call for existing subscription upgrade - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "DE" && @@ -814,7 +814,7 @@ public class PreviewOrganizationTaxCommandTests } }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -822,7 +822,7 @@ public class PreviewOrganizationTaxCommandTests Total = 6600 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, planChange, billingAddress); @@ -832,7 +832,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(66.00m, total); // Verify the correct Stripe API call preserves existing discount - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -876,8 +876,8 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal("Organization does not have a subscription.", badRequest.Response); // Verify no Stripe API calls were made - await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); - await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateInvoicePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); } #endregion @@ -919,7 +919,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -927,7 +927,7 @@ public class PreviewOrganizationTaxCommandTests Total = 6600 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -937,7 +937,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(66.00m, total); // Verify the correct Stripe API call for PM seats only - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -984,7 +984,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -992,7 +992,7 @@ public class PreviewOrganizationTaxCommandTests Total = 13200 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -1002,7 +1002,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(132.00m, total); // Verify the correct Stripe API call for PM seats + storage - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "CA" && @@ -1051,7 +1051,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -1059,7 +1059,7 @@ public class PreviewOrganizationTaxCommandTests Total = 8800 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -1069,7 +1069,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(88.00m, total); // Verify the correct Stripe API call for SM seats only - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "DE" && @@ -1119,7 +1119,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -1127,7 +1127,7 @@ public class PreviewOrganizationTaxCommandTests Total = 16500 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -1137,7 +1137,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(165.00m, total); // Verify the correct Stripe API call for SM seats + service accounts with tax ID - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "GB" && @@ -1200,7 +1200,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -1208,7 +1208,7 @@ public class PreviewOrganizationTaxCommandTests Total = 27500 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -1218,7 +1218,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(275.00m, total); // Verify the correct Stripe API call for comprehensive update with discount and Spanish tax ID - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "ES" && @@ -1276,7 +1276,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -1284,7 +1284,7 @@ public class PreviewOrganizationTaxCommandTests Total = 5500 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -1294,7 +1294,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(55.00m, total); // Verify the correct Stripe API call for Families tier (personal usage, no business tax exemption) - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "AU" && @@ -1334,8 +1334,8 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal("Organization does not have a subscription.", badRequest.Response); // Verify no Stripe API calls were made - await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); - await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateInvoicePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -1378,7 +1378,7 @@ public class PreviewOrganizationTaxCommandTests Customer = customer }; - _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_test123", Arg.Any()).Returns(subscription); var invoice = new Invoice { @@ -1386,7 +1386,7 @@ public class PreviewOrganizationTaxCommandTests Total = 3300 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(organization, update); @@ -1396,7 +1396,7 @@ public class PreviewOrganizationTaxCommandTests Assert.Equal(33.00m, total); // Verify only PM seats are included (storage=0 excluded, SM seats=0 so entire SM excluded) - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && diff --git a/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs index 617a136fab..0ceb257c88 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs @@ -8,7 +8,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Platform.Installations; -using Bit.Core.Services; using Bit.Core.Test.AutoFixture; using Bit.Core.Test.Billing.AutoFixture; using Bit.Test.Common.AutoFixture; @@ -59,7 +58,7 @@ public class GetCloudOrganizationLicenseQueryTests { installation.Enabled = true; sutProvider.GetDependency().GetByIdAsync(installationId).Returns(installation); - sutProvider.GetDependency().GetSubscriptionAsync(organization).Returns(subInfo); + sutProvider.GetDependency().GetSubscriptionAsync(organization).Returns(subInfo); sutProvider.GetDependency().SignLicense(Arg.Any()).Returns(licenseSignature); var result = await sutProvider.Sut.GetLicenseAsync(organization, installationId); @@ -80,7 +79,7 @@ public class GetCloudOrganizationLicenseQueryTests { installation.Enabled = true; sutProvider.GetDependency().GetByIdAsync(installationId).Returns(installation); - sutProvider.GetDependency().GetSubscriptionAsync(organization).Returns(subInfo); + sutProvider.GetDependency().GetSubscriptionAsync(organization).Returns(subInfo); sutProvider.GetDependency().SignLicense(Arg.Any()).Returns(licenseSignature); sutProvider.GetDependency() .CreateOrganizationTokenAsync(organization, installationId, subInfo) @@ -119,7 +118,7 @@ public class GetCloudOrganizationLicenseQueryTests installation.Enabled = true; sutProvider.GetDependency().GetByIdAsync(installationId).Returns(installation); sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); - sutProvider.GetDependency().GetSubscriptionAsync(provider).Returns(subInfo); + sutProvider.GetDependency().GetSubscriptionAsync(provider).Returns(subInfo); sutProvider.GetDependency().SignLicense(Arg.Any()).Returns(licenseSignature); var result = await sutProvider.Sut.GetLicenseAsync(organization, installationId); diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index 05d24bdc34..a7284410fe 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -8,7 +8,6 @@ using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -382,7 +381,7 @@ public class GetOrganizationWarningsQueryTests var dueDate = now.AddDays(-10); - sutProvider.GetDependency().InvoiceSearchAsync(Arg.Is(options => + sutProvider.GetDependency().SearchInvoiceAsync(Arg.Is(options => options.Query == $"subscription:'{subscriptionId}' status:'open'")).Returns([ new Invoice { DueDate = dueDate, Created = dueDate.AddDays(-30) } ]); @@ -542,7 +541,7 @@ public class GetOrganizationWarningsQueryTests .Returns(true); sutProvider.GetDependency() - .TaxRegistrationsListAsync(Arg.Any()) + .ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = new List @@ -583,7 +582,7 @@ public class GetOrganizationWarningsQueryTests .Returns(true); sutProvider.GetDependency() - .TaxRegistrationsListAsync(Arg.Any()) + .ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = new List @@ -635,7 +634,7 @@ public class GetOrganizationWarningsQueryTests .Returns(true); sutProvider.GetDependency() - .TaxRegistrationsListAsync(Arg.Any()) + .ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = new List @@ -687,7 +686,7 @@ public class GetOrganizationWarningsQueryTests .Returns(true); sutProvider.GetDependency() - .TaxRegistrationsListAsync(Arg.Any()) + .ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = new List @@ -739,7 +738,7 @@ public class GetOrganizationWarningsQueryTests .Returns(true); sutProvider.GetDependency() - .TaxRegistrationsListAsync(Arg.Any()) + .ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = new List @@ -785,7 +784,7 @@ public class GetOrganizationWarningsQueryTests .Returns(true); sutProvider.GetDependency() - .TaxRegistrationsListAsync(Arg.Any()) + .ListTaxRegistrationsAsync(Arg.Any()) .Returns(new StripeList { Data = new List diff --git a/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs index c42049d5bb..5854d1c3b5 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; -using Bit.Core.Services; using Bit.Core.Test.Billing.Extensions; using Microsoft.Extensions.Logging; using NSubstitute; @@ -73,7 +72,7 @@ public class UpdateBillingAddressCommandTests } }; - _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Address.Matches(input) && options.HasExpansions("subscriptions") )).Returns(customer); @@ -84,7 +83,7 @@ public class UpdateBillingAddressCommandTests var output = result.AsT0; Assert.Equivalent(input, output); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); } @@ -131,7 +130,7 @@ public class UpdateBillingAddressCommandTests } }; - _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Address.Matches(input) && options.HasExpansions("subscriptions") )).Returns(customer); @@ -144,7 +143,7 @@ public class UpdateBillingAddressCommandTests await _subscriberService.Received(1).CreateStripeCustomer(organization); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); } @@ -192,7 +191,7 @@ public class UpdateBillingAddressCommandTests } }; - _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Address.Matches(input) && options.HasExpansions("subscriptions", "tax_ids") && options.TaxExempt == TaxExempt.None @@ -204,7 +203,7 @@ public class UpdateBillingAddressCommandTests var output = result.AsT0; Assert.Equivalent(input, output); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); } @@ -260,7 +259,7 @@ public class UpdateBillingAddressCommandTests } }; - _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Address.Matches(input) && options.HasExpansions("subscriptions", "tax_ids") && options.TaxExempt == TaxExempt.None @@ -272,10 +271,10 @@ public class UpdateBillingAddressCommandTests var output = result.AsT0; Assert.Equivalent(input, output); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); - await _stripeAdapter.Received(1).TaxIdDeleteAsync(customer.Id, "tax_id_123"); + await _stripeAdapter.Received(1).DeleteTaxIdAsync(customer.Id, "tax_id_123"); } [Fact] @@ -322,7 +321,7 @@ public class UpdateBillingAddressCommandTests } }; - _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Address.Matches(input) && options.HasExpansions("subscriptions", "tax_ids") && options.TaxExempt == TaxExempt.Reverse @@ -334,7 +333,7 @@ public class UpdateBillingAddressCommandTests var output = result.AsT0; Assert.Equivalent(input, output); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); } @@ -384,14 +383,14 @@ public class UpdateBillingAddressCommandTests } }; - _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is(options => + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(options => options.Address.Matches(input) && options.HasExpansions("subscriptions", "tax_ids") && options.TaxExempt == TaxExempt.Reverse )).Returns(customer); _stripeAdapter - .TaxIdCreateAsync(customer.Id, + .CreateTaxIdAsync(customer.Id, Arg.Is(options => options.Type == TaxIdType.EUVAT)) .Returns(new TaxId { Type = TaxIdType.EUVAT, Value = "ESA12345678" }); @@ -401,10 +400,10 @@ public class UpdateBillingAddressCommandTests var output = result.AsT0; Assert.Equivalent(input with { TaxId = new TaxID(TaxIdType.EUVAT, "ESA12345678") }, output); - await _stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); - await _stripeAdapter.Received(1).TaxIdCreateAsync(organization.GatewayCustomerId, Arg.Is( + await _stripeAdapter.Received(1).CreateTaxIdAsync(organization.GatewayCustomerId, Arg.Is( options => options.Type == TaxIdType.SpanishNIF && options.Value == input.TaxId.Value)); } diff --git a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs index 72280c4c77..da42127f33 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs @@ -4,7 +4,6 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Test.Billing.Extensions; using Braintree; @@ -82,7 +81,7 @@ public class UpdatePaymentMethodCommandTests Status = "requires_action" }; - _stripeAdapter.SetupIntentList(Arg.Is(options => + _stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == token && options.HasExpansions("data.payment_method"))).Returns([setupIntent]); var result = await _command.Run(organization, @@ -144,7 +143,7 @@ public class UpdatePaymentMethodCommandTests Status = "requires_action" }; - _stripeAdapter.SetupIntentList(Arg.Is(options => + _stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == token && options.HasExpansions("data.payment_method"))).Returns([setupIntent]); var result = await _command.Run(organization, @@ -213,7 +212,7 @@ public class UpdatePaymentMethodCommandTests Status = "requires_action" }; - _stripeAdapter.SetupIntentList(Arg.Is(options => + _stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == token && options.HasExpansions("data.payment_method"))).Returns([setupIntent]); var result = await _command.Run(organization, @@ -232,7 +231,7 @@ public class UpdatePaymentMethodCommandTests Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); - await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, Arg.Is(options => + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is(options => options.Metadata[MetadataKeys.BraintreeCustomerId] == string.Empty && options.Metadata[MetadataKeys.RetiredBraintreeCustomerId] == "braintree_customer_id")); } @@ -262,7 +261,7 @@ public class UpdatePaymentMethodCommandTests const string token = "TOKEN"; _stripeAdapter - .PaymentMethodAttachAsync(token, + .AttachPaymentMethodAsync(token, Arg.Is(options => options.Customer == customer.Id)) .Returns(new PaymentMethod { @@ -291,7 +290,7 @@ public class UpdatePaymentMethodCommandTests Assert.Equal("9999", maskedCard.Last4); Assert.Equal("01/2028", maskedCard.Expiration); - await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is(options => options.InvoiceSettings.DefaultPaymentMethod == token)); } @@ -315,7 +314,7 @@ public class UpdatePaymentMethodCommandTests const string token = "TOKEN"; _stripeAdapter - .PaymentMethodAttachAsync(token, + .AttachPaymentMethodAsync(token, Arg.Is(options => options.Customer == customer.Id)) .Returns(new PaymentMethod { @@ -344,10 +343,10 @@ public class UpdatePaymentMethodCommandTests Assert.Equal("9999", maskedCard.Last4); Assert.Equal("01/2028", maskedCard.Expiration); - await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is(options => options.InvoiceSettings.DefaultPaymentMethod == token)); - await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is(options => options.Address.Country == "US" && options.Address.PostalCode == "12345")); } @@ -468,7 +467,7 @@ public class UpdatePaymentMethodCommandTests var maskedPayPalAccount = maskedPaymentMethod.AsT2; Assert.Equal("user@gmail.com", maskedPayPalAccount.Email); - await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, Arg.Is(options => options.Metadata[MetadataKeys.BraintreeCustomerId] == "braintree_customer_id")); } diff --git a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs index b6b0d596b3..4e4c5199e2 100644 --- a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs +++ b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs @@ -3,7 +3,6 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; -using Bit.Core.Services; using Bit.Core.Test.Billing.Extensions; using Braintree; using Microsoft.Extensions.Logging; @@ -166,7 +165,7 @@ public class GetPaymentMethodQueryTests _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter - .SetupIntentGet("seti_123", + .GetSetupIntentAsync("seti_123", Arg.Is(options => options.HasExpansions("payment_method"))).Returns( new SetupIntent { diff --git a/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs index c7ab0c17ff..9ade4d0979 100644 --- a/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs +++ b/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs @@ -3,7 +3,6 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; -using Bit.Core.Services; using Bit.Core.Test.Billing.Extensions; using NSubstitute; using NSubstitute.ReturnsExtensions; @@ -57,7 +56,7 @@ public class HasPaymentMethodQueryTests _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter - .SetupIntentGet("seti_123", + .GetSetupIntentAsync("seti_123", Arg.Is(options => options.HasExpansions("payment_method"))) .Returns(new SetupIntent { @@ -162,7 +161,7 @@ public class HasPaymentMethodQueryTests _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter - .SetupIntentGet("seti_123", + .GetSetupIntentAsync("seti_123", Arg.Is(options => options.HasExpansions("payment_method"))) .Returns(new SetupIntent { @@ -246,7 +245,7 @@ public class HasPaymentMethodQueryTests _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter - .SetupIntentGet("seti_123", + .GetSetupIntentAsync("seti_123", Arg.Is(options => options.HasExpansions("payment_method"))) .Returns(new SetupIntent { diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index cc9c409b4a..b58b5cd250 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -146,11 +146,11 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSetupIntent = Substitute.For(); mockSetupIntent.Id = "seti_123"; - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); - _stripeAdapter.SetupIntentList(Arg.Any()).Returns(Task.FromResult(new List { mockSetupIntent })); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.ListSetupIntentsAsync(Arg.Any()).Returns(Task.FromResult(new List { mockSetupIntent })); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act @@ -158,8 +158,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); - await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); } @@ -200,10 +200,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act @@ -211,8 +211,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); - await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); } @@ -243,10 +243,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); @@ -255,8 +255,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); - await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); @@ -299,10 +299,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act @@ -356,8 +356,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Mock that the user has a payment method (this is the key difference from the credit purchase case) _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act var result = await _command.Run(user, paymentMethod, billingAddress, 0); @@ -365,7 +365,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); - await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); await _updatePaymentMethodCommand.DidNotReceive().Run(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -415,8 +415,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _updatePaymentMethodCommand.Run(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(mockMaskedPaymentMethod); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act var result = await _command.Run(user, paymentMethod, billingAddress, 0); @@ -428,9 +428,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Verify GetCustomerOrThrow was called after updating payment method await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); // Verify no new customer was created - await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); // Verify subscription was created - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); // Verify user was updated correctly Assert.True(user.Premium); await _userService.Received(1).SaveUserAsync(user); @@ -474,10 +474,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); // Act @@ -525,10 +525,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act @@ -577,10 +577,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); // Act @@ -628,13 +628,13 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SetupIntentList(Arg.Any()) + _stripeAdapter.ListSetupIntentsAsync(Arg.Any()) .Returns(Task.FromResult(new List())); // Empty list - no setup intent found // Act @@ -681,8 +681,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); - _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act var result = await _command.Run(user, paymentMethod, billingAddress, 0); @@ -690,7 +690,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); - await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); Assert.True(user.Premium); Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); } @@ -716,8 +716,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT3); // Assuming T3 is the Unhandled result Assert.IsType(result.AsT3.Exception); // Verify no customer was created or subscription attempted - await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); - await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); } @@ -767,8 +767,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests ] }; - _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); // Act var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index d0b2eb7aa4..b5afaf65cd 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -1,7 +1,7 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; -using Bit.Core.Services; +using Bit.Core.Billing.Services; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; @@ -50,7 +50,7 @@ public class PreviewPremiumTaxCommandTests Total = 3300 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(0, billingAddress); @@ -59,7 +59,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(3.00m, tax); Assert.Equal(33.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -84,7 +84,7 @@ public class PreviewPremiumTaxCommandTests Total = 5500 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(5, billingAddress); @@ -93,7 +93,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(5.00m, tax); Assert.Equal(55.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "CA" && @@ -120,7 +120,7 @@ public class PreviewPremiumTaxCommandTests Total = 2750 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(0, billingAddress); @@ -129,7 +129,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(2.50m, tax); Assert.Equal(27.50m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "GB" && @@ -154,7 +154,7 @@ public class PreviewPremiumTaxCommandTests Total = 8800 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(20, billingAddress); @@ -163,7 +163,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(8.00m, tax); Assert.Equal(88.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "DE" && @@ -190,7 +190,7 @@ public class PreviewPremiumTaxCommandTests Total = 4950 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(10, billingAddress); @@ -199,7 +199,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(4.50m, tax); Assert.Equal(49.50m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "AU" && @@ -226,7 +226,7 @@ public class PreviewPremiumTaxCommandTests Total = 3000 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(0, billingAddress); @@ -235,7 +235,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(0.00m, tax); Assert.Equal(30.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "US" && @@ -260,7 +260,7 @@ public class PreviewPremiumTaxCommandTests Total = 6600 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(-5, billingAddress); @@ -269,7 +269,7 @@ public class PreviewPremiumTaxCommandTests Assert.Equal(6.00m, tax); Assert.Equal(66.00m, total); - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.Currency == "usd" && options.CustomerDetails.Address.Country == "FR" && @@ -295,7 +295,7 @@ public class PreviewPremiumTaxCommandTests Total = 3123 // $31.23 }; - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); var result = await _command.Run(0, billingAddress); diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 4060b45528..0ca1ecfe73 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -10,7 +10,6 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -179,7 +178,7 @@ public class OrganizationBillingServiceTests SubscriptionCreateOptions capturedOptions = null; sutProvider.GetDependency() - .SubscriptionCreateAsync(Arg.Do(options => capturedOptions = options)) + .CreateSubscriptionAsync(Arg.Do(options => capturedOptions = options)) .Returns(new Subscription { Id = "sub_test123", @@ -196,7 +195,7 @@ public class OrganizationBillingServiceTests // Assert await sutProvider.GetDependency() .Received(1) - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); Assert.NotNull(capturedOptions); Assert.Equal(7, capturedOptions.TrialPeriodDays); @@ -255,7 +254,7 @@ public class OrganizationBillingServiceTests SubscriptionCreateOptions capturedOptions = null; sutProvider.GetDependency() - .SubscriptionCreateAsync(Arg.Do(options => capturedOptions = options)) + .CreateSubscriptionAsync(Arg.Do(options => capturedOptions = options)) .Returns(new Subscription { Id = "sub_test123", @@ -272,7 +271,7 @@ public class OrganizationBillingServiceTests // Assert await sutProvider.GetDependency() .Received(1) - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); Assert.NotNull(capturedOptions); Assert.Equal(0, capturedOptions.TrialPeriodDays); @@ -329,7 +328,7 @@ public class OrganizationBillingServiceTests SubscriptionCreateOptions capturedOptions = null; sutProvider.GetDependency() - .SubscriptionCreateAsync(Arg.Do(options => capturedOptions = options)) + .CreateSubscriptionAsync(Arg.Do(options => capturedOptions = options)) .Returns(new Subscription { Id = "sub_test123", @@ -346,7 +345,7 @@ public class OrganizationBillingServiceTests // Assert await sutProvider.GetDependency() .Received(1) - .SubscriptionCreateAsync(Arg.Any()); + .CreateSubscriptionAsync(Arg.Any()); Assert.NotNull(capturedOptions); Assert.Equal(7, capturedOptions.TrialPeriodDays); @@ -364,7 +363,7 @@ public class OrganizationBillingServiceTests CustomerUpdateOptions capturedOptions = null; sutProvider.GetDependency() - .CustomerUpdateAsync( + .UpdateCustomerAsync( Arg.Is(id => id == organization.GatewayCustomerId), Arg.Do(options => capturedOptions = options)) .Returns(new Customer()); @@ -375,7 +374,7 @@ public class OrganizationBillingServiceTests // Assert await sutProvider.GetDependency() .Received(1) - .CustomerUpdateAsync( + .UpdateCustomerAsync( organization.GatewayCustomerId, Arg.Any()); @@ -401,7 +400,7 @@ public class OrganizationBillingServiceTests CustomerUpdateOptions capturedOptions = null; sutProvider.GetDependency() - .CustomerUpdateAsync( + .UpdateCustomerAsync( Arg.Is(id => id == organization.GatewayCustomerId), Arg.Do(options => capturedOptions = options)) .Returns(new Customer()); @@ -412,7 +411,7 @@ public class OrganizationBillingServiceTests // Assert await sutProvider.GetDependency() .Received(1) - .CustomerUpdateAsync( + .UpdateCustomerAsync( organization.GatewayCustomerId, Arg.Any()); @@ -445,6 +444,6 @@ public class OrganizationBillingServiceTests await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateCustomerAsync(Arg.Any(), Arg.Any()); } } diff --git a/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs b/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs index 06a408c5a8..cd4c5effbe 100644 --- a/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs +++ b/test/Core.Test/Billing/Services/PaymentHistoryServiceTests.cs @@ -1,9 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Models.BitStripe; using Bit.Core.Repositories; -using Bit.Core.Services; using NSubstitute; using Stripe; using Xunit; @@ -19,7 +19,7 @@ public class PaymentHistoryServiceTests var subscriber = new Organization { GatewayCustomerId = "cus_id", GatewaySubscriptionId = "sub_id" }; var invoices = new List { new() { Id = "in_id" } }; var stripeAdapter = Substitute.For(); - stripeAdapter.InvoiceListAsync(Arg.Any()).Returns(invoices); + stripeAdapter.ListInvoicesAsync(Arg.Any()).Returns(invoices); var transactionRepository = Substitute.For(); var paymentHistoryService = new PaymentHistoryService(stripeAdapter, transactionRepository); @@ -29,7 +29,7 @@ public class PaymentHistoryServiceTests // Assert Assert.NotEmpty(result); Assert.Single(result); - await stripeAdapter.Received(1).InvoiceListAsync(Arg.Any()); + await stripeAdapter.Received(1).ListInvoicesAsync(Arg.Any()); } [Fact] diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Billing/Services/StripePaymentServiceTests.cs similarity index 96% rename from test/Core.Test/Services/StripePaymentServiceTests.cs rename to test/Core.Test/Billing/Services/StripePaymentServiceTests.cs index 8f556be57a..73f28113ca 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Billing/Services/StripePaymentServiceTests.cs @@ -1,7 +1,8 @@ using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -49,7 +50,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -100,7 +101,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -159,7 +160,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -198,7 +199,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -256,7 +257,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -295,7 +296,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -332,7 +333,7 @@ public class StripePaymentServiceTests }; sutProvider.GetDependency() - .SubscriptionGetAsync( + .GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Any()) .Returns(subscription); @@ -367,7 +368,7 @@ public class StripePaymentServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .SubscriptionGetAsync( + .GetSubscriptionAsync( Arg.Any(), Arg.Any()) .Returns(subscription); @@ -376,7 +377,7 @@ public class StripePaymentServiceTests await sutProvider.Sut.GetSubscriptionAsync(subscriber); // Assert - Verify expand options are correct - await stripeAdapter.Received(1).SubscriptionGetAsync( + await stripeAdapter.Received(1).GetSubscriptionAsync( subscriber.GatewaySubscriptionId, Arg.Is(o => o.Expand.Contains("customer.discount.coupon.applies_to") && @@ -405,6 +406,6 @@ public class StripePaymentServiceTests // Verify no Stripe API calls were made await sutProvider.GetDependency() .DidNotReceive() - .SubscriptionGetAsync(Arg.Any(), Arg.Any()); + .GetSubscriptionAsync(Arg.Any(), Arg.Any()); } } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 50fb160754..2f938065e5 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -3,10 +3,10 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.Tax.Models; using Bit.Core.Enums; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -44,7 +44,7 @@ public class SubscriberServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(subscription); await ThrowsBillingExceptionAsync(() => @@ -52,11 +52,11 @@ public class SubscriberServiceTests await stripeAdapter .DidNotReceiveWithAnyArgs() - .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); await stripeAdapter .DidNotReceiveWithAnyArgs() - .SubscriptionCancelAsync(Arg.Any(), Arg.Any()); + .CancelSubscriptionAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -81,7 +81,7 @@ public class SubscriberServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(subscription); var offboardingSurveyResponse = new OffboardingSurveyResponse @@ -95,12 +95,12 @@ public class SubscriberServiceTests await stripeAdapter .Received(1) - .SubscriptionUpdateAsync(subscriptionId, Arg.Is( + .UpdateSubscriptionAsync(subscriptionId, Arg.Is( options => options.Metadata["cancellingUserId"] == userId.ToString())); await stripeAdapter .Received(1) - .SubscriptionCancelAsync(subscriptionId, Arg.Is(options => + .CancelSubscriptionAsync(subscriptionId, Arg.Is(options => options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason)); } @@ -127,7 +127,7 @@ public class SubscriberServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(subscription); var offboardingSurveyResponse = new OffboardingSurveyResponse @@ -141,11 +141,11 @@ public class SubscriberServiceTests await stripeAdapter .DidNotReceiveWithAnyArgs() - .SubscriptionUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); await stripeAdapter .Received(1) - .SubscriptionCancelAsync(subscriptionId, Arg.Is(options => + .CancelSubscriptionAsync(subscriptionId, Arg.Is(options => options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason)); } @@ -170,7 +170,7 @@ public class SubscriberServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(subscription); var offboardingSurveyResponse = new OffboardingSurveyResponse @@ -184,7 +184,7 @@ public class SubscriberServiceTests await stripeAdapter .Received(1) - .SubscriptionUpdateAsync(subscriptionId, Arg.Is(options => + .UpdateSubscriptionAsync(subscriptionId, Arg.Is(options => options.CancelAtPeriodEnd == true && options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback && options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason && @@ -192,7 +192,7 @@ public class SubscriberServiceTests await stripeAdapter .DidNotReceiveWithAnyArgs() - .SubscriptionCancelAsync(Arg.Any(), Arg.Any()); + .CancelSubscriptionAsync(Arg.Any(), Arg.Any()); } #endregion @@ -223,7 +223,7 @@ public class SubscriberServiceTests SutProvider sutProvider) { sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId) + .GetCustomerAsync(organization.GatewayCustomerId) .ReturnsNull(); var customer = await sutProvider.Sut.GetCustomer(organization); @@ -237,7 +237,7 @@ public class SubscriberServiceTests SutProvider sutProvider) { sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId) + .GetCustomerAsync(organization.GatewayCustomerId) .ThrowsAsync(); var customer = await sutProvider.Sut.GetCustomer(organization); @@ -253,7 +253,7 @@ public class SubscriberServiceTests var customer = new Customer(); sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId) + .GetCustomerAsync(organization.GatewayCustomerId) .Returns(customer); var gotCustomer = await sutProvider.Sut.GetCustomer(organization); @@ -287,7 +287,7 @@ public class SubscriberServiceTests SutProvider sutProvider) { sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId) + .GetCustomerAsync(organization.GatewayCustomerId) .ReturnsNull(); await ThrowsBillingExceptionAsync(async () => await sutProvider.Sut.GetCustomerOrThrow(organization)); @@ -301,7 +301,7 @@ public class SubscriberServiceTests var stripeException = new StripeException(); sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId) + .GetCustomerAsync(organization.GatewayCustomerId) .ThrowsAsync(stripeException); await ThrowsBillingExceptionAsync( @@ -318,7 +318,7 @@ public class SubscriberServiceTests var customer = new Customer(); sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId) + .GetCustomerAsync(organization.GatewayCustomerId) .Returns(customer); var gotCustomer = await sutProvider.Sut.GetCustomerOrThrow(organization); @@ -351,7 +351,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("default_source") && options.Expand.Contains("invoice_settings.default_payment_method"))) @@ -388,7 +388,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("default_source") && options.Expand.Contains("invoice_settings.default_payment_method"))) @@ -442,7 +442,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("default_source") && options.Expand.Contains("invoice_settings.default_payment_method"))) @@ -478,7 +478,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("default_source") && options.Expand.Contains("invoice_settings.default_payment_method"))) @@ -498,7 +498,7 @@ public class SubscriberServiceTests { var customer = new Customer { Id = provider.GatewayCustomerId }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is(options => options.Expand.Contains("default_source") && options.Expand.Contains( "invoice_settings.default_payment_method"))) @@ -521,7 +521,7 @@ public class SubscriberServiceTests sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); - sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, + sutProvider.GetDependency().GetSetupIntentAsync(setupIntent.Id, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -541,7 +541,7 @@ public class SubscriberServiceTests DefaultSource = new BankAccount { Status = "verified", BankName = "Chase", Last4 = "9999" } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is(options => options.Expand.Contains("default_source") && options.Expand.Contains( "invoice_settings.default_payment_method"))) @@ -564,7 +564,7 @@ public class SubscriberServiceTests DefaultSource = new Card { Brand = "Visa", Last4 = "9999", ExpMonth = 9, ExpYear = 2028 } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is(options => options.Expand.Contains("default_source") && options.Expand.Contains( "invoice_settings.default_payment_method"))) @@ -596,7 +596,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("default_source") && options.Expand.Contains("invoice_settings.default_payment_method"))) @@ -636,7 +636,7 @@ public class SubscriberServiceTests SutProvider sutProvider) { sutProvider.GetDependency() - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .ReturnsNull(); var subscription = await sutProvider.Sut.GetSubscription(organization); @@ -650,7 +650,7 @@ public class SubscriberServiceTests SutProvider sutProvider) { sutProvider.GetDependency() - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .ThrowsAsync(); var subscription = await sutProvider.Sut.GetSubscription(organization); @@ -666,7 +666,7 @@ public class SubscriberServiceTests var subscription = new Subscription(); sutProvider.GetDependency() - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(subscription); var gotSubscription = await sutProvider.Sut.GetSubscription(organization); @@ -698,7 +698,7 @@ public class SubscriberServiceTests SutProvider sutProvider) { sutProvider.GetDependency() - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .ReturnsNull(); await ThrowsBillingExceptionAsync(async () => await sutProvider.Sut.GetSubscriptionOrThrow(organization)); @@ -712,7 +712,7 @@ public class SubscriberServiceTests var stripeException = new StripeException(); sutProvider.GetDependency() - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .ThrowsAsync(stripeException); await ThrowsBillingExceptionAsync( @@ -729,7 +729,7 @@ public class SubscriberServiceTests var subscription = new Subscription(); sutProvider.GetDependency() - .SubscriptionGetAsync(organization.GatewaySubscriptionId) + .GetSubscriptionAsync(organization.GatewaySubscriptionId) .Returns(subscription); var gotSubscription = await sutProvider.Sut.GetSubscriptionOrThrow(organization); @@ -760,7 +760,7 @@ public class SubscriberServiceTests }; sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .GetCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(stripeCustomer); var (braintreeGateway, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); @@ -795,7 +795,7 @@ public class SubscriberServiceTests }; sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .GetCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(stripeCustomer); var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); @@ -832,7 +832,7 @@ public class SubscriberServiceTests }; sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .GetCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(stripeCustomer); var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); @@ -887,7 +887,7 @@ public class SubscriberServiceTests }; sutProvider.GetDependency() - .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .GetCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(stripeCustomer); var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); @@ -946,21 +946,21 @@ public class SubscriberServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .GetCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(stripeCustomer); stripeAdapter - .PaymentMethodListAutoPagingAsync(Arg.Any()) + .ListPaymentMethodsAutoPagingAsync(Arg.Any()) .Returns(GetPaymentMethodsAsync(new List())); await sutProvider.Sut.RemovePaymentSource(organization); - await stripeAdapter.Received(1).BankAccountDeleteAsync(stripeCustomer.Id, bankAccountId); + await stripeAdapter.Received(1).DeleteBankAccountAsync(stripeCustomer.Id, bankAccountId); - await stripeAdapter.Received(1).CardDeleteAsync(stripeCustomer.Id, cardId); + await stripeAdapter.Received(1).DeleteCardAsync(stripeCustomer.Id, cardId); await stripeAdapter.DidNotReceiveWithAnyArgs() - .PaymentMethodDetachAsync(Arg.Any(), Arg.Any()); + .DetachPaymentMethodAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -978,11 +978,11 @@ public class SubscriberServiceTests var stripeAdapter = sutProvider.GetDependency(); stripeAdapter - .CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .GetCustomerAsync(organization.GatewayCustomerId, Arg.Any()) .Returns(stripeCustomer); stripeAdapter - .PaymentMethodListAutoPagingAsync(Arg.Any()) + .ListPaymentMethodsAutoPagingAsync(Arg.Any()) .Returns(GetPaymentMethodsAsync(new List { new () @@ -997,15 +997,15 @@ public class SubscriberServiceTests await sutProvider.Sut.RemovePaymentSource(organization); - await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any(), Arg.Any()); + await stripeAdapter.DidNotReceiveWithAnyArgs().DeleteBankAccountAsync(Arg.Any(), Arg.Any()); - await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any(), Arg.Any()); + await stripeAdapter.DidNotReceiveWithAnyArgs().DeleteCardAsync(Arg.Any(), Arg.Any()); await stripeAdapter.Received(1) - .PaymentMethodDetachAsync(bankAccountId); + .DetachPaymentMethodAsync(bankAccountId); await stripeAdapter.Received(1) - .PaymentMethodDetachAsync(cardId); + .DetachPaymentMethodAsync(cardId); } private static async IAsyncEnumerable GetPaymentMethodsAsync( @@ -1050,7 +1050,7 @@ public class SubscriberServiceTests Provider provider, SutProvider sutProvider) { - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId) .Returns(new Customer()); await ThrowsBillingExceptionAsync(() => @@ -1062,7 +1062,7 @@ public class SubscriberServiceTests Provider provider, SutProvider sutProvider) { - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId) .Returns(new Customer()); await ThrowsBillingExceptionAsync(() => @@ -1076,10 +1076,10 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId) + stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId) .Returns(new Customer()); - stripeAdapter.SetupIntentList(Arg.Is(options => options.PaymentMethod == "TOKEN")) + stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == "TOKEN")) .Returns([new SetupIntent(), new SetupIntent()]); await ThrowsBillingExceptionAsync(() => @@ -1093,7 +1093,7 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerGetAsync( + stripeAdapter.GetCustomerAsync( provider.GatewayCustomerId, Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))) .Returns(new Customer @@ -1107,10 +1107,10 @@ public class SubscriberServiceTests var matchingSetupIntent = new SetupIntent { Id = "setup_intent_1" }; - stripeAdapter.SetupIntentList(Arg.Is(options => options.PaymentMethod == "TOKEN")) + stripeAdapter.ListSetupIntentsAsync(Arg.Is(options => options.PaymentMethod == "TOKEN")) .Returns([matchingSetupIntent]); - stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ + stripeAdapter.ListCustomerPaymentMethodsAsync(provider.GatewayCustomerId).Returns([ new PaymentMethod { Id = "payment_method_1" } ]); @@ -1119,12 +1119,12 @@ public class SubscriberServiceTests await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_1"); - await stripeAdapter.DidNotReceive().SetupIntentCancel(Arg.Any(), + await stripeAdapter.DidNotReceive().CancelSetupIntentAsync(Arg.Any(), Arg.Any()); - await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); + await stripeAdapter.Received(1).DetachPaymentMethodAsync("payment_method_1"); - await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null)); } @@ -1135,7 +1135,7 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerGetAsync( + stripeAdapter.GetCustomerAsync( provider.GatewayCustomerId, Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids")) ) @@ -1148,22 +1148,22 @@ public class SubscriberServiceTests } }); - stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ + stripeAdapter.ListCustomerPaymentMethodsAsync(provider.GatewayCustomerId).Returns([ new PaymentMethod { Id = "payment_method_1" } ]); await sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.Card, "TOKEN")); - await stripeAdapter.DidNotReceive().SetupIntentCancel(Arg.Any(), + await stripeAdapter.DidNotReceive().CancelSetupIntentAsync(Arg.Any(), Arg.Any()); - await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); + await stripeAdapter.Received(1).DetachPaymentMethodAsync("payment_method_1"); - await stripeAdapter.Received(1).PaymentMethodAttachAsync("TOKEN", Arg.Is( + await stripeAdapter.Received(1).AttachPaymentMethodAsync("TOKEN", Arg.Is( options => options.Customer == provider.GatewayCustomerId)); - await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.InvoiceSettings.DefaultPaymentMethod == "TOKEN" && options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null)); @@ -1176,7 +1176,7 @@ public class SubscriberServiceTests { const string braintreeCustomerId = "braintree_customer_id"; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId) .Returns(new Customer { Id = provider.GatewayCustomerId, @@ -1202,7 +1202,7 @@ public class SubscriberServiceTests { const string braintreeCustomerId = "braintree_customer_id"; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId) .Returns(new Customer { Id = provider.GatewayCustomerId, @@ -1240,7 +1240,7 @@ public class SubscriberServiceTests { const string braintreeCustomerId = "braintree_customer_id"; - sutProvider.GetDependency().CustomerGetAsync( + sutProvider.GetDependency().GetCustomerAsync( provider.GatewayCustomerId, Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))) .Returns(new Customer @@ -1294,7 +1294,7 @@ public class SubscriberServiceTests { const string braintreeCustomerId = "braintree_customer_id"; - sutProvider.GetDependency().CustomerGetAsync( + sutProvider.GetDependency().GetCustomerAsync( provider.GatewayCustomerId, Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))) .Returns(new Customer @@ -1363,7 +1363,7 @@ public class SubscriberServiceTests { const string braintreeCustomerId = "braintree_customer_id"; - sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + sutProvider.GetDependency().GetCustomerAsync(provider.GatewayCustomerId) .Returns(new Customer { Id = provider.GatewayCustomerId @@ -1395,7 +1395,7 @@ public class SubscriberServiceTests new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN"))); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + .UpdateCustomerAsync(Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -1405,7 +1405,7 @@ public class SubscriberServiceTests { const string braintreeCustomerId = "braintree_customer_id"; - sutProvider.GetDependency().CustomerGetAsync( + sutProvider.GetDependency().GetCustomerAsync( provider.GatewayCustomerId, Arg.Is(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))) .Returns(new Customer @@ -1442,7 +1442,7 @@ public class SubscriberServiceTests await sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.PayPal, "TOKEN")); - await sutProvider.GetDependency().Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, + await sutProvider.GetDependency().Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == braintreeCustomerId)); } @@ -1473,7 +1473,7 @@ public class SubscriberServiceTests var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } }; - stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( + stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("tax_ids"))).Returns(customer); var taxInformation = new TaxInformation( @@ -1487,7 +1487,7 @@ public class SubscriberServiceTests "NY"); sutProvider.GetDependency() - .CustomerUpdateAsync( + .UpdateCustomerAsync( Arg.Is(p => p == provider.GatewayCustomerId), Arg.Is(options => options.Address.Country == "US" && @@ -1522,12 +1522,12 @@ public class SubscriberServiceTests }); var subscription = new Subscription { Items = new StripeList() }; - sutProvider.GetDependency().SubscriptionGetAsync(Arg.Any()) + sutProvider.GetDependency().GetSubscriptionAsync(Arg.Any()) .Returns(subscription); await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation); - await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Address.Country == taxInformation.Country && options.Address.PostalCode == taxInformation.PostalCode && @@ -1536,13 +1536,13 @@ public class SubscriberServiceTests options.Address.City == taxInformation.City && options.Address.State == taxInformation.State)); - await stripeAdapter.Received(1).TaxIdDeleteAsync(provider.GatewayCustomerId, "tax_id_1"); + await stripeAdapter.Received(1).DeleteTaxIdAsync(provider.GatewayCustomerId, "tax_id_1"); - await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is( + await stripeAdapter.Received(1).CreateTaxIdAsync(provider.GatewayCustomerId, Arg.Is( options => options.Type == "us_ein" && options.Value == taxInformation.TaxId)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); } @@ -1555,7 +1555,7 @@ public class SubscriberServiceTests var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } }; - stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( + stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("tax_ids"))).Returns(customer); var taxInformation = new TaxInformation( @@ -1569,7 +1569,7 @@ public class SubscriberServiceTests "NY"); sutProvider.GetDependency() - .CustomerUpdateAsync( + .UpdateCustomerAsync( Arg.Is(p => p == provider.GatewayCustomerId), Arg.Is(options => options.Address.Country == "CA" && @@ -1605,12 +1605,12 @@ public class SubscriberServiceTests }); var subscription = new Subscription { Items = new StripeList() }; - sutProvider.GetDependency().SubscriptionGetAsync(Arg.Any()) + sutProvider.GetDependency().GetSubscriptionAsync(Arg.Any()) .Returns(subscription); await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation); - await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is( options => options.Address.Country == taxInformation.Country && options.Address.PostalCode == taxInformation.PostalCode && @@ -1619,16 +1619,16 @@ public class SubscriberServiceTests options.Address.City == taxInformation.City && options.Address.State == taxInformation.State)); - await stripeAdapter.Received(1).TaxIdDeleteAsync(provider.GatewayCustomerId, "tax_id_1"); + await stripeAdapter.Received(1).DeleteTaxIdAsync(provider.GatewayCustomerId, "tax_id_1"); - await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is( + await stripeAdapter.Received(1).CreateTaxIdAsync(provider.GatewayCustomerId, Arg.Is( options => options.Type == "us_ein" && options.Value == taxInformation.TaxId)); - await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, + await stripeAdapter.Received(1).UpdateCustomerAsync(provider.GatewayCustomerId, Arg.Is(options => options.TaxExempt == StripeConstants.TaxExempt.Reverse)); - await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId, Arg.Is(options => options.AutomaticTax.Enabled == true)); } @@ -1655,7 +1655,7 @@ public class SubscriberServiceTests Assert.True(result); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any()); + .GetCustomerAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1669,7 +1669,7 @@ public class SubscriberServiceTests Assert.True(result); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any()); + .GetCustomerAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1678,12 +1678,12 @@ public class SubscriberServiceTests SutProvider sutProvider) { var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId).Returns(new Customer()); + stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId).Returns(new Customer()); var result = await sutProvider.Sut.IsValidGatewayCustomerIdAsync(organization); Assert.True(result); - await stripeAdapter.Received(1).CustomerGetAsync(organization.GatewayCustomerId); + await stripeAdapter.Received(1).GetCustomerAsync(organization.GatewayCustomerId); } [Theory, BitAutoData] @@ -1693,12 +1693,12 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); var stripeException = new StripeException { StripeError = new StripeError { Code = "resource_missing" } }; - stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId).Throws(stripeException); + stripeAdapter.GetCustomerAsync(organization.GatewayCustomerId).Throws(stripeException); var result = await sutProvider.Sut.IsValidGatewayCustomerIdAsync(organization); Assert.False(result); - await stripeAdapter.Received(1).CustomerGetAsync(organization.GatewayCustomerId); + await stripeAdapter.Received(1).GetCustomerAsync(organization.GatewayCustomerId); } #endregion @@ -1724,7 +1724,7 @@ public class SubscriberServiceTests Assert.True(result); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .SubscriptionGetAsync(Arg.Any()); + .GetSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1738,7 +1738,7 @@ public class SubscriberServiceTests Assert.True(result); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .SubscriptionGetAsync(Arg.Any()); + .GetSubscriptionAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1747,12 +1747,12 @@ public class SubscriberServiceTests SutProvider sutProvider) { var stripeAdapter = sutProvider.GetDependency(); - stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId).Returns(new Subscription()); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Returns(new Subscription()); var result = await sutProvider.Sut.IsValidGatewaySubscriptionIdAsync(organization); Assert.True(result); - await stripeAdapter.Received(1).SubscriptionGetAsync(organization.GatewaySubscriptionId); + await stripeAdapter.Received(1).GetSubscriptionAsync(organization.GatewaySubscriptionId); } [Theory, BitAutoData] @@ -1762,12 +1762,12 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); var stripeException = new StripeException { StripeError = new StripeError { Code = "resource_missing" } }; - stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId).Throws(stripeException); + stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId).Throws(stripeException); var result = await sutProvider.Sut.IsValidGatewaySubscriptionIdAsync(organization); Assert.False(result); - await stripeAdapter.Received(1).SubscriptionGetAsync(organization.GatewaySubscriptionId); + await stripeAdapter.Received(1).GetSubscriptionAsync(organization.GatewaySubscriptionId); } #endregion diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs index 570f94575f..41f8839eb4 100644 --- a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -6,7 +6,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Entities; using Bit.Core.Repositories; -using Bit.Core.Services; using NSubstitute; using Stripe; using Xunit; @@ -98,13 +97,13 @@ public class RestartSubscriptionCommandTests }; _subscriberService.GetSubscription(organization).Returns(existingSubscription); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); var result = await _command.Run(organization); Assert.True(result.IsT0); - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is((SubscriptionCreateOptions options) => + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is((SubscriptionCreateOptions options) => options.AutomaticTax.Enabled == true && options.CollectionMethod == CollectionMethod.ChargeAutomatically && options.Customer == "cus_123" && @@ -154,13 +153,13 @@ public class RestartSubscriptionCommandTests }; _subscriberService.GetSubscription(provider).Returns(existingSubscription); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); var result = await _command.Run(provider); Assert.True(result.IsT0); - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _providerRepository.Received(1).ReplaceAsync(Arg.Is(prov => prov.Id == providerId && @@ -199,13 +198,13 @@ public class RestartSubscriptionCommandTests }; _subscriberService.GetSubscription(user).Returns(existingSubscription); - _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); var result = await _command.Run(user); Assert.True(result.IsT0); - await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => u.Id == userId && diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommandTestsBase.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommandTestsBase.cs index 786a6f6c0d..a6db6ae8fd 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommandTestsBase.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommandTestsBase.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Repositories; using Bit.Core.Services; @@ -12,7 +13,7 @@ public abstract class CancelSponsorshipCommandTestsBase : FamiliesForEnterpriseT protected async Task AssertRemovedSponsoredPaymentAsync(Organization sponsoredOrg, OrganizationSponsorship sponsorship, SutProvider sutProvider) { - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .RemoveOrganizationSponsorshipAsync(sponsoredOrg, sponsorship); await sutProvider.GetDependency().Received(1).UpsertAsync(sponsoredOrg); if (sponsorship != null) @@ -46,7 +47,7 @@ OrganizationSponsorship sponsorship, SutProvider sutProvider) protected static async Task AssertDidNotRemoveSponsoredPaymentAsync(SutProvider sutProvider) { - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .RemoveOrganizationSponsorshipAsync(default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .UpsertAsync(default); diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs index 69e7183c65..127cc7e502 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs @@ -1,10 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Test.AutoFixture.OrganizationSponsorshipFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -82,7 +82,7 @@ public class SetUpSponsorshipCommandTests : FamiliesForEnterpriseTestsBase private static async Task AssertDidNotSetUpAsync(SutProvider sutProvider) { - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SponsorOrganizationAsync(default, default); await sutProvider.GetDependency() diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs index fb64c11312..83e1487b01 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; @@ -54,7 +55,7 @@ public class AddSecretsManagerSubscriptionCommandTests c.AdditionalServiceAccounts == additionalServiceAccounts && c.AdditionalSeats == organization.Seats.GetValueOrDefault())); - await sutProvider.GetDependency().Received() + await sutProvider.GetDependency().Received() .AddSecretsManagerToSubscription(organization, plan, additionalSmSeats, additionalServiceAccounts); // TODO: call ReferenceEventService - see AC-1481 @@ -150,7 +151,7 @@ public class AddSecretsManagerSubscriptionCommandTests private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) { - await sutProvider.GetDependency().DidNotReceive() + await sutProvider.GetDependency().DidNotReceive() .AddSecretsManagerToSubscription(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); // TODO: call ReferenceEventService - see AC-1481 diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index baa9e04c22..510433a2fa 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -86,9 +87,9 @@ public class UpdateSecretsManagerSubscriptionCommandTests await sutProvider.Sut.UpdateSubscriptionAsync(update); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .AdjustServiceAccountsAsync(organization, plan, update.SmServiceAccountsExcludingBase); // TODO: call ReferenceEventService - see AC-1481 @@ -136,9 +137,9 @@ public class UpdateSecretsManagerSubscriptionCommandTests await sutProvider.Sut.UpdateSubscriptionAsync(update); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .AdjustServiceAccountsAsync(organization, plan, update.SmServiceAccountsExcludingBase); // TODO: call ReferenceEventService - see AC-1481 @@ -258,7 +259,7 @@ public class UpdateSecretsManagerSubscriptionCommandTests await sutProvider.Sut.UpdateSubscriptionAsync(update); - await sutProvider.GetDependency().Received(1).AdjustServiceAccountsAsync( + await sutProvider.GetDependency().Received(1).AdjustServiceAccountsAsync( Arg.Is(o => o.Id == organizationId), plan, expectedSmServiceAccountsExcludingBase); @@ -779,9 +780,9 @@ public class UpdateSecretsManagerSubscriptionCommandTests private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) { - await sutProvider.GetDependency().DidNotReceive() + await sutProvider.GetDependency().DidNotReceive() .AdjustSmSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency().DidNotReceive() + await sutProvider.GetDependency().DidNotReceive() .AdjustServiceAccountsAsync(Arg.Any(), Arg.Any(), Arg.Any()); // TODO: call ReferenceEventService - see AC-1481 await sutProvider.GetDependency().DidNotReceive() diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 3841f7a619..8a00604bb0 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -121,7 +122,7 @@ public class UpgradeOrganizationPlanCommandTests Users = 1 }); await sutProvider.Sut.UpgradePlanAsync(organization.Id, organizationUpgrade); - await sutProvider.GetDependency().Received(1).AdjustSubscription( + await sutProvider.GetDependency().Received(1).AdjustSubscription( organization, MockPlans.Get(planType), organizationUpgrade.AdditionalSeats, diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index a41cd43923..a9b3e6f7f0 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -227,7 +227,7 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory services.AddSingleton(); // Noop StripePaymentService - this could be changed to integrate with our Stripe test account - Replace(services, Substitute.For()); + Replace(services, Substitute.For()); Replace(services, Substitute.For()); }); From ed76fe2ab63c6234ba1ac076143d1b8a0a68d85f Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:49:32 -0500 Subject: [PATCH 07/47] Refactor configuration for azure queue service for events to include queue name (#6724) * Refactor configuration for azure queue service for events to include queue name * Address PR feedback * Add check for queue name before writing to Azure Queue Service * Fix file encoding (lint error) --- dev/secrets.json.example | 4 ++++ ...IntegrationsServiceCollectionExtensions.cs | 3 ++- .../AzureQueueEventWriteService.cs | 2 +- src/Core/Settings/GlobalSettings.cs | 20 ++++++++++++++++++- .../AzureQueueHostedService.cs | 12 ++++++----- ...grationServiceCollectionExtensionsTests.cs | 3 ++- 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/dev/secrets.json.example b/dev/secrets.json.example index c6a16846e9..0d4213aec1 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -33,6 +33,10 @@ "id": "", "key": "" }, + "events": { + "connectionString": "", + "queueName": "event" + }, "licenseDirectory": "", "enableNewDeviceVerification": true, "enableEmailVerification": true diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index bd8ebf6483..5dce52d907 100644 --- a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -91,7 +91,8 @@ public static class EventIntegrationsServiceCollectionExtensions return services; } - if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) + if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString) && + CoreHelpers.SettingHasValue(globalSettings.Events.QueueName)) { services.TryAddSingleton(); return services; diff --git a/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs index f81175f7b5..4f48b64b5a 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs @@ -8,7 +8,7 @@ namespace Bit.Core.Services; public class AzureQueueEventWriteService : AzureQueueService, IEventWriteService { public AzureQueueEventWriteService(GlobalSettings globalSettings) : base( - new QueueClient(globalSettings.Events.ConnectionString, "event"), + new QueueClient(globalSettings.Events.ConnectionString, globalSettings.Events.QueueName), JsonHelpers.IgnoreWritingNull) { } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index b0d7da05a2..ddc48521e3 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -56,7 +56,7 @@ public class GlobalSettings : IGlobalSettings public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); - public virtual ConnectionStringSettings Events { get; set; } = new ConnectionStringSettings(); + public virtual AzureQueueEventSettings Events { get; set; } = new AzureQueueEventSettings(); public virtual DistributedCacheSettings DistributedCache { get; set; } = new DistributedCacheSettings(); public virtual NotificationsSettings Notifications { get; set; } = new NotificationsSettings(); public virtual IFileStorageSettings Attachment { get; set; } @@ -395,6 +395,24 @@ public class GlobalSettings : IGlobalSettings } } + public class AzureQueueEventSettings : IConnectionStringSettings + { + private string _connectionString; + private string _queueName; + + public string ConnectionString + { + get => _connectionString; + set => _connectionString = value?.Trim('"'); + } + + public string QueueName + { + get => _queueName; + set => _queueName = value?.Trim('"'); + } + } + public class ConnectionStringSettings : IConnectionStringSettings { private string _connectionString; diff --git a/src/EventsProcessor/AzureQueueHostedService.cs b/src/EventsProcessor/AzureQueueHostedService.cs index 8dc0f12c0c..c4c02e32d2 100644 --- a/src/EventsProcessor/AzureQueueHostedService.cs +++ b/src/EventsProcessor/AzureQueueHostedService.cs @@ -6,6 +6,7 @@ using Azure.Storage.Queues; using Bit.Core; using Bit.Core.Models.Data; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Utilities; namespace Bit.EventsProcessor; @@ -13,7 +14,7 @@ namespace Bit.EventsProcessor; public class AzureQueueHostedService : IHostedService, IDisposable { private readonly ILogger _logger; - private readonly IConfiguration _configuration; + private readonly GlobalSettings _globalSettings; private Task _executingTask; private CancellationTokenSource _cts; @@ -22,10 +23,10 @@ public class AzureQueueHostedService : IHostedService, IDisposable public AzureQueueHostedService( ILogger logger, - IConfiguration configuration) + GlobalSettings globalSettings) { _logger = logger; - _configuration = configuration; + _globalSettings = globalSettings; } public Task StartAsync(CancellationToken cancellationToken) @@ -56,11 +57,12 @@ public class AzureQueueHostedService : IHostedService, IDisposable private async Task ExecuteAsync(CancellationToken cancellationToken) { - var storageConnectionString = _configuration["azureStorageConnectionString"]; - var queueName = _configuration["azureQueueServiceQueueName"]; + var storageConnectionString = _globalSettings.Events.ConnectionString; + var queueName = _globalSettings.Events.QueueName; if (string.IsNullOrWhiteSpace(storageConnectionString) || string.IsNullOrWhiteSpace(queueName)) { + _logger.LogInformation("Azure Queue Hosted Service is disabled. Missing connection string or queue name."); return; } diff --git a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs index aca783ade2..08fcd23969 100644 --- a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -727,7 +727,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var services = new ServiceCollection(); var globalSettings = CreateGlobalSettings(new Dictionary { - ["GlobalSettings:Events:ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test;EndpointSuffix=core.windows.net" + ["GlobalSettings:Events:ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test;EndpointSuffix=core.windows.net", + ["GlobalSettings:Events:QueueName"] = "event" }); services.AddEventWriteServices(globalSettings); From 082233f761725d03eb93b5d8f1643f3366a47e6a Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 15 Dec 2025 15:36:38 +0000 Subject: [PATCH 08/47] Bumped version to 2025.12.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 221200147c..b7a99562ea 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.12.0 + 2025.12.1 Bit.$(MSBuildProjectName) enable From 4caf89f1399d6432222b19dd5c68af948cc6c31b Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 15 Dec 2025 15:42:14 +0000 Subject: [PATCH 09/47] Bumped version to 2025.12.2 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b7a99562ea..db3ccf40f5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.12.1 + 2025.12.2 Bit.$(MSBuildProjectName) enable From e9ba7ba315405274500afe934eb41100d483904b Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:45:01 -0500 Subject: [PATCH 10/47] Add the Server SDK to Billing (#6727) --- global.json | 3 ++- src/Billing/Billing.csproj | 8 ++++++++ src/Billing/Program.cs | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/global.json b/global.json index d25197db39..4cbe3f083a 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,7 @@ }, "msbuild-sdks": { "Microsoft.Build.Traversal": "4.1.0", - "Microsoft.Build.Sql": "1.0.0" + "Microsoft.Build.Sql": "1.0.0", + "Bitwarden.Server.Sdk": "1.2.0" } } diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index fdac4fc3e4..69999dc795 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -1,9 +1,17 @@  + bitwarden-Billing + + + false + false + false + + diff --git a/src/Billing/Program.cs b/src/Billing/Program.cs index 72ff6072c5..334dc49368 100644 --- a/src/Billing/Program.cs +++ b/src/Billing/Program.cs @@ -8,6 +8,7 @@ public class Program { Host .CreateDefaultBuilder(args) + .UseBitwardenSdk() .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); From 4f7e76dac75b066d234a883631667f6e8215ccd4 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 15 Dec 2025 17:48:37 +0100 Subject: [PATCH 11/47] [PM-27279] Implement TDE Registration with V2 Keys (#6671) * Implement TDE v2 signup * Clean up fallback logic for account keys * Fix broken v2 logic * Add comment * Update comment --- .../Auth/Controllers/AccountsController.cs | 36 ++++++++- src/Api/Models/Response/KeysResponseModel.cs | 27 ++++--- .../Api/Request/Accounts/KeysRequestModel.cs | 5 ++ src/Core/Constants.cs | 1 + .../Controllers/AccountsControllerTests.cs | 80 ++++++++++++++++++- .../Controllers/AccountsControllerTests.cs | 6 ++ .../Endpoints/IdentityServerTests.cs | 17 ++++ 7 files changed, 156 insertions(+), 16 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index ecf49c18c8..38981b7a2d 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -18,6 +18,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; @@ -44,6 +45,7 @@ public class AccountsController : Controller private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IChangeKdfCommand _changeKdfCommand; + private readonly IUserRepository _userRepository; public AccountsController( IOrganizationService organizationService, @@ -57,7 +59,8 @@ public class AccountsController : Controller IFeatureService featureService, IUserAccountKeysQuery userAccountKeysQuery, ITwoFactorEmailService twoFactorEmailService, - IChangeKdfCommand changeKdfCommand + IChangeKdfCommand changeKdfCommand, + IUserRepository userRepository ) { _organizationService = organizationService; @@ -72,6 +75,7 @@ public class AccountsController : Controller _userAccountKeysQuery = userAccountKeysQuery; _twoFactorEmailService = twoFactorEmailService; _changeKdfCommand = changeKdfCommand; + _userRepository = userRepository; } @@ -440,8 +444,31 @@ public class AccountsController : Controller } } - await _userService.SaveUserAsync(model.ToUser(user)); - return new KeysResponseModel(user); + if (model.AccountKeys != null) + { + var accountKeysData = model.AccountKeys.ToAccountKeysData(); + if (!accountKeysData.IsV2Encryption()) + { + throw new BadRequestException("AccountKeys are only supported for V2 encryption."); + } + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, accountKeysData); + return new KeysResponseModel(accountKeysData, user.Key); + } + else + { + // Todo: Drop this after a transition period. This will drop no-account-keys requests. + // The V1 check in the other branch should persist + // https://bitwarden.atlassian.net/browse/PM-27329 + await _userService.SaveUserAsync(model.ToUser(user)); + return new KeysResponseModel(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + user.PrivateKey, + user.PublicKey + ) + }, user.Key); + } + } [HttpGet("keys")] @@ -453,7 +480,8 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - return new KeysResponseModel(user); + var accountKeys = await _userAccountKeysQuery.Run(user); + return new KeysResponseModel(accountKeys, user.Key); } [HttpDelete] diff --git a/src/Api/Models/Response/KeysResponseModel.cs b/src/Api/Models/Response/KeysResponseModel.cs index cfc1a6a0a1..4c877e0bfc 100644 --- a/src/Api/Models/Response/KeysResponseModel.cs +++ b/src/Api/Models/Response/KeysResponseModel.cs @@ -1,27 +1,32 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Api.Response; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; public class KeysResponseModel : ResponseModel { - public KeysResponseModel(User user) + public KeysResponseModel(UserAccountKeysData accountKeys, string? masterKeyWrappedUserKey) : base("keys") { - if (user == null) + if (masterKeyWrappedUserKey != null) { - throw new ArgumentNullException(nameof(user)); + Key = masterKeyWrappedUserKey; } - Key = user.Key; - PublicKey = user.PublicKey; - PrivateKey = user.PrivateKey; + PublicKey = accountKeys.PublicKeyEncryptionKeyPairData.PublicKey; + PrivateKey = accountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey; + AccountKeys = new PrivateKeysResponseModel(accountKeys); } - public string Key { get; set; } + /// + /// The master key wrapped user key. The master key can either be a master-password master key or a + /// key-connector master key. + /// + public string? Key { get; set; } + [Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.PublicKey instead")] public string PublicKey { get; set; } + [Obsolete("Use AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey instead")] public string PrivateKey { get; set; } + public PrivateKeysResponseModel AccountKeys { get; set; } } diff --git a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs index f89b67f3c5..85ddef44ce 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs @@ -3,17 +3,22 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; public class KeysRequestModel { + [Obsolete("Use AccountKeys.AccountPublicKey instead")] [Required] public string PublicKey { get; set; } + [Obsolete("Use AccountKeys.UserKeyEncryptedAccountPrivateKey instead")] [Required] public string EncryptedPrivateKey { get; set; } + public AccountKeysRequestModel AccountKeys { get; set; } + [Obsolete("Use SetAccountKeysForUserCommand instead")] public User ToUser(User existingUser) { if (string.IsNullOrWhiteSpace(PublicKey) || string.IsNullOrWhiteSpace(EncryptedPrivateKey)) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6d2c2a1673..7b01f8355d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -211,6 +211,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 V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; /* Mobile Team */ diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index f1aa11d068..5a8497a73e 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.Repositories; @@ -38,6 +39,7 @@ public class AccountsControllerTests : IDisposable private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly IChangeKdfCommand _changeKdfCommand; + private readonly IUserRepository _userRepository; public AccountsControllerTests() { @@ -53,6 +55,7 @@ public class AccountsControllerTests : IDisposable _userAccountKeysQuery = Substitute.For(); _twoFactorEmailService = Substitute.For(); _changeKdfCommand = Substitute.For(); + _userRepository = Substitute.For(); _sut = new AccountsController( _organizationService, @@ -66,7 +69,8 @@ public class AccountsControllerTests : IDisposable _featureService, _userAccountKeysQuery, _twoFactorEmailService, - _changeKdfCommand + _changeKdfCommand, + _userRepository ); } @@ -738,5 +742,79 @@ public class AccountsControllerTests : IDisposable _userService.GetUserByIdAsync(Arg.Any()) .Returns(Task.FromResult((User)null)); } + + [Theory, BitAutoData] + public async Task PostKeys_WithAccountKeys_CallsSetV2AccountCryptographicState( + User user, + KeysRequestModel model) + { + // Arrange + user.PublicKey = "public-key"; + user.PrivateKey = "encrypted-private-key"; + model.AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "wrapped-private-key", + AccountPublicKey = "public-key", + PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel + { + PublicKey = "public-key", + WrappedPrivateKey = "wrapped-private-key", + SignedPublicKey = "signed-public-key" + }, + SignatureKeyPair = new SignatureKeyPairRequestModel + { + VerifyingKey = "verifying-key", + SignatureAlgorithm = "ed25519", + WrappedSigningKey = "wrapped-signing-key" + }, + SecurityState = new SecurityStateModel + { + SecurityState = "security-state", + SecurityVersion = 2 + } + }; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false); + + // Act + var result = await _sut.PostKeys(model); + + // Assert + await _userRepository.Received(1).SetV2AccountCryptographicStateAsync( + user.Id, + Arg.Any()); + await _userService.DidNotReceiveWithAnyArgs().SaveUserAsync(Arg.Any()); + Assert.NotNull(result); + Assert.Equal("keys", result.Object); + } + + [Theory, BitAutoData] + public async Task PostKeys_WithoutAccountKeys_CallsSaveUser( + User user, + KeysRequestModel model) + { + // Arrange + user.PublicKey = null; + user.PrivateKey = null; + model.AccountKeys = null; + model.PublicKey = "public-key"; + model.EncryptedPrivateKey = "encrypted-private-key"; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false); + + // Act + var result = await _sut.PostKeys(model); + + // Assert + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.PublicKey == model.PublicKey && + u.PrivateKey == model.EncryptedPrivateKey)); + await _userRepository.DidNotReceiveWithAnyArgs() + .SetV2AccountCryptographicStateAsync(Arg.Any(), Arg.Any()); + Assert.NotNull(result); + Assert.Equal("keys", result.Object); + } } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 8325dcf1bb..79da4d0aae 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -139,6 +139,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize substitutions to this test. var localFactory = new IdentityApplicationFactory(); @@ -202,6 +203,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize substitutions to this test. var localFactory = new IdentityApplicationFactory(); localFactory.UpdateConfiguration("globalSettings:disableUserRegistration", "true"); @@ -233,6 +235,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); @@ -310,6 +313,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism, Guid orgSponsorshipId) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); @@ -386,6 +390,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism, EmergencyAccess emergencyAccess) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); @@ -455,6 +460,7 @@ public class AccountsControllerTests : IClassFixture [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) { + userAsymmetricKeys.AccountKeys = null; // Localize factory to just this test. var localFactory = new IdentityApplicationFactory(); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 6f10f22002..9f5fc2aaea 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -21,6 +21,13 @@ namespace Bit.Identity.IntegrationTest.Endpoints; [SutProviderCustomize] public class IdentityServerTests : IClassFixture { + private static readonly KeysRequestModel TEST_ACCOUNT_KEYS = new KeysRequestModel + { + AccountKeys = null, + PublicKey = "public-key", + EncryptedPrivateKey = "encrypted-private-key", + }; + private const int SecondsInMinute = 60; private const int MinutesInHour = 60; private const int SecondsInHour = SecondsInMinute * MinutesInHour; @@ -53,6 +60,7 @@ public class IdentityServerTests : IClassFixture [Theory, BitAutoData, RegisterFinishRequestModelCustomize] public async Task TokenEndpoint_GrantTypePassword_Success(RegisterFinishRequestModel requestModel) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; var localFactory = new IdentityApplicationFactory(); var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); @@ -78,6 +86,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersTrue_Success( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -103,6 +112,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyDisabled_WithEnforceSsoPolicyForAllUsersFalse_Success( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -129,6 +139,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithAllUserTypes_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersTrue_Throw( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -152,6 +163,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Success( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -175,6 +187,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_GrantTypePassword_WithNonOwnerOrAdmin_WithSsoPolicyEnabled_WithEnforceSsoPolicyForAllUsersFalse_Throws( OrganizationUserType organizationUserType, RegisterFinishRequestModel requestModel, Guid organizationId, int generatedUsername) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; requestModel.Email = $"{generatedUsername}@example.com"; var localFactory = new IdentityApplicationFactory(); @@ -196,6 +209,7 @@ public class IdentityServerTests : IClassFixture [Theory, BitAutoData, RegisterFinishRequestModelCustomize] public async Task TokenEndpoint_GrantTypeRefreshToken_Success(RegisterFinishRequestModel requestModel) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; var localFactory = new IdentityApplicationFactory(); var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); @@ -218,6 +232,7 @@ public class IdentityServerTests : IClassFixture [Theory, BitAutoData, RegisterFinishRequestModelCustomize] public async Task TokenEndpoint_GrantTypeClientCredentials_Success(RegisterFinishRequestModel model) { + model.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; var localFactory = new IdentityApplicationFactory(); var user = await localFactory.RegisterNewIdentityFactoryUserAsync(model); @@ -242,6 +257,7 @@ public class IdentityServerTests : IClassFixture RegisterFinishRequestModel model, string deviceId) { + model.UserAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); var server = localFactory.WithWebHostBuilder(builder => { @@ -445,6 +461,7 @@ public class IdentityServerTests : IClassFixture public async Task TokenEndpoint_TooQuickInOneSecond_BlockRequest( RegisterFinishRequestModel requestModel) { + requestModel.UserAsymmetricKeys = TEST_ACCOUNT_KEYS; const int AmountInOneSecondAllowed = 10; // The rule we are testing is 10 requests in 1 second From acfe0d7223f8495afc9523f88076f69f637fec68 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:38:53 -0500 Subject: [PATCH 12/47] chore(README): Adjust README header level for better formatting * Adjust headers for better formatting. * Fixed formatting. --- .../RequestValidators/SendAccess/readme.md | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md index afab13a156..2a6ea66857 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md @@ -1,17 +1,15 @@ -Send Access Request Validation -=== +# Send Access Request Validation This feature supports the ability of Tools to require specific claims for access to sends. In order to access Send data a user must meet the requirements laid out in these request validators. -# ***Important: String Constants*** - -The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. +> [!IMPORTANT] +> The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. There is snapshot testing that will fail if the strings change to help detect unintended changes to the string constants. -# Custom Claims +## Custom Claims Send access tokens contain custom claims specific to the Send the Send grant type. @@ -19,41 +17,41 @@ Send access tokens contain custom claims specific to the Send the Send grant typ 1. `send_email` - only set when the Send requires `EmailOtp` authentication type. 1. `type` - this will always be `Send` -# Authentication methods +## Authentication methods -## `NeverAuthenticate` +### `NeverAuthenticate` For a Send to be in this state two things can be true: 1. The Send has been modified and no longer allows access. 2. The Send does not exist. -## `NotAuthenticated` +### `NotAuthenticated` In this scenario the Send is not protected by any added authentication or authorization and the access token is issued to the requesting user. -## `ResourcePassword` +### `ResourcePassword` In this scenario the Send is password protected and a user must supply the correct password hash to be issued an access token. -## `EmailOtp` +### `EmailOtp` In this scenario the Send is only accessible to owners of specific email addresses. The user must submit a correct email. Once the email has been entered then ownership of the email must be established via OTP. The Otp is sent to the aforementioned email and must be supplied, along with the email, to be issued an access token. -# Send Access Request Validation +## Send Access Request Validation -## Required Parameters +### Required Parameters -### All Requests +#### All Requests - `send_id` - Base64 URL-encoded GUID of the send being accessed -### Password Protected Sends +#### Password Protected Sends - `password_hash_b64` - client hashed Base64-encoded password. -### Email OTP Protected Sends +#### Email OTP Protected Sends - `email` - Email address associated with the send - `otp` - One-time password (optional - if missing, OTP is generated and sent) -## Error Responses +### Error Responses All errors include a custom response field: ```json @@ -62,5 +60,4 @@ All errors include a custom response field: "error_description": "Human readable description", "send_access_error_type": "specific_error_code" } -``` - +``` \ No newline at end of file From d554e4ef1572e97664c40e02ff1e3e8fc7ef059f Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:05:10 -0600 Subject: [PATCH 13/47] [PM-29203] Remove UserkeyRotationV2 feature flag (#6716) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7b01f8355d..3d9f2cca87 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -201,7 +201,6 @@ public static class FeatureFlagKeys public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; 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 EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; From 7cfdb4ddfcbba4181d4b593c94cdb8eeadb5e993 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Mon, 15 Dec 2025 12:12:07 -0600 Subject: [PATCH 14/47] PM-23358 removing phishing blocker code (#6668) --- .../Controllers/PhishingDomainsController.cs | 34 ----- src/Api/Jobs/JobsHostedService.cs | 9 -- src/Api/Jobs/UpdatePhishingDomainsJob.cs | 97 -------------- src/Api/Startup.cs | 1 - .../Utilities/ServiceCollectionExtensions.cs | 25 ---- src/Api/appsettings.Development.json | 4 - src/Api/appsettings.json | 3 - .../AzurePhishingDomainStorageService.cs | 95 ------------- .../CloudPhishingDomainDirectQuery.cs | 100 -------------- .../CloudPhishingDomainRelayQuery.cs | 69 ---------- .../Interfaces/ICloudPhishingDomainQuery.cs | 7 - .../Repositories/IPhishingDomainRepository.cs | 8 -- .../AzurePhishingDomainRepository.cs | 126 ------------------ src/Core/Settings/GlobalSettings.cs | 7 - src/Core/Settings/IGlobalSettings.cs | 1 - src/Core/Settings/IPhishingDomainSettings.cs | 7 - 16 files changed, 593 deletions(-) delete mode 100644 src/Api/Controllers/PhishingDomainsController.cs delete mode 100644 src/Api/Jobs/UpdatePhishingDomainsJob.cs delete mode 100644 src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs delete mode 100644 src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs delete mode 100644 src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs delete mode 100644 src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs delete mode 100644 src/Core/Repositories/IPhishingDomainRepository.cs delete mode 100644 src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs delete mode 100644 src/Core/Settings/IPhishingDomainSettings.cs diff --git a/src/Api/Controllers/PhishingDomainsController.cs b/src/Api/Controllers/PhishingDomainsController.cs deleted file mode 100644 index f0c1a65648..0000000000 --- a/src/Api/Controllers/PhishingDomainsController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bit.Core; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Controllers; - -[Route("phishing-domains")] -public class PhishingDomainsController(IPhishingDomainRepository phishingDomainRepository, IFeatureService featureService) : Controller -{ - [HttpGet] - public async Task>> GetPhishingDomainsAsync() - { - if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - return NotFound(); - } - - var domains = await phishingDomainRepository.GetActivePhishingDomainsAsync(); - return Ok(domains); - } - - [HttpGet("checksum")] - public async Task> GetChecksumAsync() - { - if (!featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - return NotFound(); - } - - var checksum = await phishingDomainRepository.GetCurrentChecksumAsync(); - return Ok(checksum); - } -} diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index 0178f6d68b..a9626dc90e 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -59,13 +59,6 @@ public class JobsHostedService : BaseJobsHostedService .StartNow() .WithCronSchedule("0 0 * * * ?") .Build(); - var updatePhishingDomainsTrigger = TriggerBuilder.Create() - .WithIdentity("UpdatePhishingDomainsTrigger") - .StartNow() - .WithSimpleSchedule(x => x - .WithIntervalInHours(24) - .RepeatForever()) - .Build(); var updateOrgSubscriptionsTrigger = TriggerBuilder.Create() .WithIdentity("UpdateOrgSubscriptionsTrigger") .StartNow() @@ -81,7 +74,6 @@ public class JobsHostedService : BaseJobsHostedService new Tuple(typeof(ValidateUsersJob), everyTopOfTheSixthHourTrigger), new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), - new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger), }; @@ -111,7 +103,6 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); } diff --git a/src/Api/Jobs/UpdatePhishingDomainsJob.cs b/src/Api/Jobs/UpdatePhishingDomainsJob.cs deleted file mode 100644 index 355f2af69b..0000000000 --- a/src/Api/Jobs/UpdatePhishingDomainsJob.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Bit.Core; -using Bit.Core.Jobs; -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Settings; -using Quartz; - -namespace Bit.Api.Jobs; - -public class UpdatePhishingDomainsJob : BaseJob -{ - private readonly GlobalSettings _globalSettings; - private readonly IPhishingDomainRepository _phishingDomainRepository; - private readonly ICloudPhishingDomainQuery _cloudPhishingDomainQuery; - private readonly IFeatureService _featureService; - public UpdatePhishingDomainsJob( - GlobalSettings globalSettings, - IPhishingDomainRepository phishingDomainRepository, - ICloudPhishingDomainQuery cloudPhishingDomainQuery, - IFeatureService featureService, - ILogger logger) - : base(logger) - { - _globalSettings = globalSettings; - _phishingDomainRepository = phishingDomainRepository; - _cloudPhishingDomainQuery = cloudPhishingDomainQuery; - _featureService = featureService; - } - - protected override async Task ExecuteJobAsync(IJobExecutionContext context) - { - if (!_featureService.IsEnabled(FeatureFlagKeys.PhishingDetection)) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Feature flag is disabled."); - return; - } - - if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. No URL configured."); - return; - } - - if (_globalSettings.SelfHosted && !_globalSettings.EnableCloudCommunication) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Skipping phishing domain update. Cloud communication is disabled in global settings."); - return; - } - - var remoteChecksum = await _cloudPhishingDomainQuery.GetRemoteChecksumAsync(); - if (string.IsNullOrWhiteSpace(remoteChecksum)) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "Could not retrieve remote checksum. Skipping update."); - return; - } - - var currentChecksum = await _phishingDomainRepository.GetCurrentChecksumAsync(); - - if (string.Equals(currentChecksum, remoteChecksum, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Phishing domains list is up to date (checksum: {Checksum}). Skipping update.", - currentChecksum); - return; - } - - _logger.LogInformation(Constants.BypassFiltersEventId, - "Checksums differ (current: {CurrentChecksum}, remote: {RemoteChecksum}). Fetching updated domains from {Source}.", - currentChecksum, remoteChecksum, _globalSettings.SelfHosted ? "Bitwarden cloud API" : "external source"); - - try - { - var domains = await _cloudPhishingDomainQuery.GetPhishingDomainsAsync(); - if (!domains.Contains("phishing.testcategory.com", StringComparer.OrdinalIgnoreCase)) - { - domains.Add("phishing.testcategory.com"); - } - - if (domains.Count > 0) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Updating {Count} phishing domains with checksum {Checksum}.", - domains.Count, remoteChecksum); - await _phishingDomainRepository.UpdatePhishingDomainsAsync(domains, remoteChecksum); - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated phishing domains."); - } - else - { - _logger.LogWarning(Constants.BypassFiltersEventId, "No valid domains found in the response. Skipping update."); - } - } - catch (Exception ex) - { - _logger.LogError(Constants.BypassFiltersEventId, ex, "Error updating phishing domains."); - } - } -} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index bdbc2f8edc..2f16470cd4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -187,7 +187,6 @@ public class Startup services.AddBillingOperations(); services.AddReportingServices(); services.AddImportServices(); - services.AddPhishingDomainServices(globalSettings); services.AddSendServices(); diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index c90fc82d56..b773abf6ef 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,9 +1,5 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Core.PhishingDomainFeatures; -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Repositories.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.SecurityTasks; @@ -103,25 +99,4 @@ public static class ServiceCollectionExtensions // Admin Console authorization handlers services.AddAdminConsoleAuthorizationHandlers(); } - - public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) - { - services.AddHttpClient("PhishingDomains", client => - { - client.DefaultRequestHeaders.Add("User-Agent", globalSettings.SelfHosted ? "Bitwarden Self-Hosted" : "Bitwarden"); - client.Timeout = TimeSpan.FromSeconds(1000); // the source list is very slow - }); - - services.AddSingleton(); - services.AddSingleton(); - - if (globalSettings.SelfHosted) - { - services.AddScoped(); - } - else - { - services.AddScoped(); - } - } } diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 87e92c4516..deb0a35d84 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -38,10 +38,6 @@ "storage": { "connectionString": "UseDevelopmentStorage=true" }, - "phishingDomain": { - "updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", - "checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256" - }, "pricingUri": "https://billingpricing.qa.bitwarden.pw" } } diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index a503070d8d..8850c3d269 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -69,9 +69,6 @@ "accessKeySecret": "SECRET", "region": "SECRET" }, - "phishingDomain": { - "updateUrl": "SECRET" - }, "distributedIpRateLimiting": { "enabled": true, "maxRedisTimeoutsThreshold": 10, diff --git a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs deleted file mode 100644 index 6b76bc35f0..0000000000 --- a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs +++ /dev/null @@ -1,95 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.PhishingDomainFeatures; - -public class AzurePhishingDomainStorageService -{ - private const string _containerName = "phishingdomains"; - private const string _domainsFileName = "domains.txt"; - private const string _checksumFileName = "checksum.txt"; - - private readonly BlobServiceClient _blobServiceClient; - private readonly ILogger _logger; - private BlobContainerClient _containerClient; - - public AzurePhishingDomainStorageService( - GlobalSettings globalSettings, - ILogger logger) - { - _blobServiceClient = new BlobServiceClient(globalSettings.Storage.ConnectionString); - _logger = logger; - } - - public async Task> GetDomainsAsync() - { - await InitAsync(); - - var blobClient = _containerClient.GetBlobClient(_domainsFileName); - if (!await blobClient.ExistsAsync()) - { - return []; - } - - var response = await blobClient.DownloadAsync(); - using var streamReader = new StreamReader(response.Value.Content); - var content = await streamReader.ReadToEndAsync(); - - return [.. content - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))]; - } - - public async Task GetChecksumAsync() - { - await InitAsync(); - - var blobClient = _containerClient.GetBlobClient(_checksumFileName); - if (!await blobClient.ExistsAsync()) - { - return string.Empty; - } - - var response = await blobClient.DownloadAsync(); - using var streamReader = new StreamReader(response.Value.Content); - return (await streamReader.ReadToEndAsync()).Trim(); - } - - public async Task UpdateDomainsAsync(IEnumerable domains, string checksum) - { - await InitAsync(); - - var domainsContent = string.Join(Environment.NewLine, domains); - var domainsStream = new MemoryStream(Encoding.UTF8.GetBytes(domainsContent)); - var domainsBlobClient = _containerClient.GetBlobClient(_domainsFileName); - - await domainsBlobClient.UploadAsync(domainsStream, new BlobUploadOptions - { - HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } - }, CancellationToken.None); - - var checksumStream = new MemoryStream(Encoding.UTF8.GetBytes(checksum)); - var checksumBlobClient = _containerClient.GetBlobClient(_checksumFileName); - - await checksumBlobClient.UploadAsync(checksumStream, new BlobUploadOptions - { - HttpHeaders = new BlobHttpHeaders { ContentType = "text/plain" } - }, CancellationToken.None); - } - - private async Task InitAsync() - { - if (_containerClient is null) - { - _containerClient = _blobServiceClient.GetBlobContainerClient(_containerName); - await _containerClient.CreateIfNotExistsAsync(); - } - } -} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs deleted file mode 100644 index 420948e310..0000000000 --- a/src/Core/PhishingDomainFeatures/CloudPhishingDomainDirectQuery.cs +++ /dev/null @@ -1,100 +0,0 @@ -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.PhishingDomainFeatures; - -/// -/// Implementation of ICloudPhishingDomainQuery for cloud environments -/// that directly calls the external phishing domain source -/// -public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery -{ - private readonly IGlobalSettings _globalSettings; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - - public CloudPhishingDomainDirectQuery( - IGlobalSettings globalSettings, - IHttpClientFactory httpClientFactory, - ILogger logger) - { - _globalSettings = globalSettings; - _httpClientFactory = httpClientFactory; - _logger = logger; - } - - public async Task> GetPhishingDomainsAsync() - { - if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.UpdateUrl)) - { - throw new InvalidOperationException("Phishing domain update URL is not configured."); - } - - var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); - var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.UpdateUrl); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - return ParseDomains(content); - } - - /// - /// Gets the SHA256 checksum of the remote phishing domains list - /// - /// The SHA256 checksum as a lowercase hex string - public async Task GetRemoteChecksumAsync() - { - if (string.IsNullOrWhiteSpace(_globalSettings.PhishingDomain?.ChecksumUrl)) - { - _logger.LogWarning("Phishing domain checksum URL is not configured."); - return string.Empty; - } - - try - { - var httpClient = _httpClientFactory.CreateClient("PhishingDomains"); - var response = await httpClient.GetAsync(_globalSettings.PhishingDomain.ChecksumUrl); - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(); - return ParseChecksumResponse(content); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving phishing domain checksum from {Url}", - _globalSettings.PhishingDomain.ChecksumUrl); - return string.Empty; - } - } - - /// - /// Parses a checksum response in the format "hash *filename" - /// - private static string ParseChecksumResponse(string checksumContent) - { - if (string.IsNullOrWhiteSpace(checksumContent)) - { - return string.Empty; - } - - // Format is typically "hash *filename" - var parts = checksumContent.Split(' ', 2); - - return parts.Length > 0 ? parts[0].Trim() : string.Empty; - } - - private static List ParseDomains(string content) - { - if (string.IsNullOrWhiteSpace(content)) - { - return []; - } - - return content - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Trim()) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#")) - .ToList(); - } -} diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs deleted file mode 100644 index 6b0027062c..0000000000 --- a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs +++ /dev/null @@ -1,69 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.PhishingDomainFeatures.Interfaces; -using Bit.Core.Services; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.PhishingDomainFeatures; - -/// -/// Implementation of ICloudPhishingDomainQuery for self-hosted environments -/// that relays the request to the Bitwarden cloud API -/// -public class CloudPhishingDomainRelayQuery : BaseIdentityClientService, ICloudPhishingDomainQuery -{ - private readonly IGlobalSettings _globalSettings; - - public CloudPhishingDomainRelayQuery( - IHttpClientFactory httpFactory, - IGlobalSettings globalSettings, - ILogger logger) - : base( - httpFactory, - globalSettings.Installation.ApiUri, - globalSettings.Installation.IdentityUri, - "api.licensing", - $"installation.{globalSettings.Installation.Id}", - globalSettings.Installation.Key, - logger) - { - _globalSettings = globalSettings; - } - - public async Task> GetPhishingDomainsAsync() - { - if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) - { - throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); - } - - var result = await SendAsync(HttpMethod.Get, "phishing-domains", null, true); - return result?.ToList() ?? new List(); - } - - /// - /// Gets the SHA256 checksum of the remote phishing domains list - /// - /// The SHA256 checksum as a lowercase hex string - public async Task GetRemoteChecksumAsync() - { - if (!_globalSettings.SelfHosted || !_globalSettings.EnableCloudCommunication) - { - throw new InvalidOperationException("This query is only for self-hosted installations with cloud communication enabled."); - } - - try - { - // For self-hosted environments, we get the checksum from the Bitwarden cloud API - var result = await SendAsync(HttpMethod.Get, "phishing-domains/checksum", null, true); - return result ?? string.Empty; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving phishing domain checksum from Bitwarden cloud API"); - return string.Empty; - } - } -} diff --git a/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs b/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs deleted file mode 100644 index dac91747f7..0000000000 --- a/src/Core/PhishingDomainFeatures/Interfaces/ICloudPhishingDomainQuery.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.PhishingDomainFeatures.Interfaces; - -public interface ICloudPhishingDomainQuery -{ - Task> GetPhishingDomainsAsync(); - Task GetRemoteChecksumAsync(); -} diff --git a/src/Core/Repositories/IPhishingDomainRepository.cs b/src/Core/Repositories/IPhishingDomainRepository.cs deleted file mode 100644 index 2d653b0a43..0000000000 --- a/src/Core/Repositories/IPhishingDomainRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Bit.Core.Repositories; - -public interface IPhishingDomainRepository -{ - Task> GetActivePhishingDomainsAsync(); - Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum); - Task GetCurrentChecksumAsync(); -} diff --git a/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs b/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs deleted file mode 100644 index 2d4ea15b7e..0000000000 --- a/src/Core/Repositories/Implementations/AzurePhishingDomainRepository.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Text.Json; -using Bit.Core.PhishingDomainFeatures; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Repositories.Implementations; - -public class AzurePhishingDomainRepository : IPhishingDomainRepository -{ - private readonly AzurePhishingDomainStorageService _storageService; - private readonly IDistributedCache _cache; - private readonly ILogger _logger; - private const string _domainsCacheKey = "PhishingDomains_v1"; - private const string _checksumCacheKey = "PhishingDomains_Checksum_v1"; - private static readonly DistributedCacheEntryOptions _cacheOptions = new() - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24), - SlidingExpiration = TimeSpan.FromHours(1) - }; - - public AzurePhishingDomainRepository( - AzurePhishingDomainStorageService storageService, - IDistributedCache cache, - ILogger logger) - { - _storageService = storageService; - _cache = cache; - _logger = logger; - } - - public async Task> GetActivePhishingDomainsAsync() - { - try - { - var cachedDomains = await _cache.GetStringAsync(_domainsCacheKey); - if (!string.IsNullOrEmpty(cachedDomains)) - { - _logger.LogDebug("Retrieved phishing domains from cache"); - return JsonSerializer.Deserialize>(cachedDomains) ?? []; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to retrieve phishing domains from cache"); - } - - var domains = await _storageService.GetDomainsAsync(); - - try - { - await _cache.SetStringAsync( - _domainsCacheKey, - JsonSerializer.Serialize(domains), - _cacheOptions); - _logger.LogDebug("Stored {Count} phishing domains in cache", domains.Count); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to store phishing domains in cache"); - } - - return domains; - } - - public async Task GetCurrentChecksumAsync() - { - try - { - var cachedChecksum = await _cache.GetStringAsync(_checksumCacheKey); - if (!string.IsNullOrEmpty(cachedChecksum)) - { - _logger.LogDebug("Retrieved phishing domain checksum from cache"); - return cachedChecksum; - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to retrieve phishing domain checksum from cache"); - } - - var checksum = await _storageService.GetChecksumAsync(); - - try - { - if (!string.IsNullOrEmpty(checksum)) - { - await _cache.SetStringAsync( - _checksumCacheKey, - checksum, - _cacheOptions); - _logger.LogDebug("Stored phishing domain checksum in cache"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to store phishing domain checksum in cache"); - } - - return checksum; - } - - public async Task UpdatePhishingDomainsAsync(IEnumerable domains, string checksum) - { - var domainsList = domains.ToList(); - await _storageService.UpdateDomainsAsync(domainsList, checksum); - - try - { - await _cache.SetStringAsync( - _domainsCacheKey, - JsonSerializer.Serialize(domainsList), - _cacheOptions); - - await _cache.SetStringAsync( - _checksumCacheKey, - checksum, - _cacheOptions); - - _logger.LogDebug("Updated phishing domains cache after update operation"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to update phishing domains in cache"); - } - } -} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index ddc48521e3..f030c73809 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -81,7 +81,6 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); - public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings(); public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } @@ -690,12 +689,6 @@ public class GlobalSettings : IGlobalSettings public int MaxNetworkRetries { get; set; } = 2; } - public class PhishingDomainSettings : IPhishingDomainSettings - { - public string UpdateUrl { get; set; } - public string ChecksumUrl { get; set; } - } - public class DistributedIpRateLimitingSettings { public string RedisConnectionString { get; set; } diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 20b832c678..06dece3394 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -28,5 +28,4 @@ public interface IGlobalSettings string DevelopmentDirectory { get; set; } IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } - IPhishingDomainSettings PhishingDomain { get; set; } } diff --git a/src/Core/Settings/IPhishingDomainSettings.cs b/src/Core/Settings/IPhishingDomainSettings.cs deleted file mode 100644 index 2e4a901a5a..0000000000 --- a/src/Core/Settings/IPhishingDomainSettings.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Settings; - -public interface IPhishingDomainSettings -{ - string UpdateUrl { get; set; } - string ChecksumUrl { get; set; } -} From 3c444309793327e28bd7bd61d4721c0b16be28bb Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:52:34 -0600 Subject: [PATCH 15/47] [PM-29161] Remove ReturnErrorOnExistingKeypair feature flag (#6726) * Remove feature flag * Add unit test coverage --- .../Auth/Controllers/AccountsController.cs | 7 +--- src/Core/Constants.cs | 1 - .../Controllers/AccountsControllerTests.cs | 37 +++++++++++++++++-- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 38981b7a2d..839d00f7a1 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -436,12 +436,9 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - if (_featureService.IsEnabled(FeatureFlagKeys.ReturnErrorOnExistingKeypair)) + if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey)) { - if (!string.IsNullOrWhiteSpace(user.PrivateKey) || !string.IsNullOrWhiteSpace(user.PublicKey)) - { - throw new BadRequestException("User has existing keypair"); - } + throw new BadRequestException("User has existing keypair"); } if (model.AccountKeys != null) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3d9f2cca87..cf3f40ec80 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -198,7 +198,6 @@ public static class FeatureFlagKeys public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode"; /* Key Management Team */ - public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string Argon2Default = "argon2-default"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 5a8497a73e..300a4d823d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -692,6 +692,37 @@ public class AccountsControllerTests : IDisposable await _sut.PostKdf(model); } + [Theory] + [BitAutoData] + public async Task PostKeys_NoUser_Errors(KeysRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(null)); + + await Assert.ThrowsAsync(() => _sut.PostKeys(model)); + } + + [Theory] + [BitAutoData("existing", "existing")] + [BitAutoData((string)null, "existing")] + [BitAutoData("", "existing")] + [BitAutoData(" ", "existing")] + [BitAutoData("existing", null)] + [BitAutoData("existing", "")] + [BitAutoData("existing", " ")] + public async Task PostKeys_UserAlreadyHasKeys_Errors(string? existingPrivateKey, string? existingPublicKey, + KeysRequestModel model) + { + var user = GenerateExampleUser(); + user.PrivateKey = existingPrivateKey; + user.PublicKey = existingPublicKey; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + + var exception = await Assert.ThrowsAsync(() => _sut.PostKeys(model)); + + Assert.NotNull(exception.Message); + Assert.Contains("User has existing keypair", exception.Message); + } + // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into // something greater in order to share common test steps with @@ -749,8 +780,8 @@ public class AccountsControllerTests : IDisposable KeysRequestModel model) { // Arrange - user.PublicKey = "public-key"; - user.PrivateKey = "encrypted-private-key"; + user.PublicKey = null; + user.PrivateKey = null; model.AccountKeys = new AccountKeysRequestModel { UserKeyEncryptedAccountPrivateKey = "wrapped-private-key", @@ -775,7 +806,6 @@ public class AccountsControllerTests : IDisposable }; _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false); // Act var result = await _sut.PostKeys(model); @@ -802,7 +832,6 @@ public class AccountsControllerTests : IDisposable model.EncryptedPrivateKey = "encrypted-private-key"; _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); - _featureService.IsEnabled(Bit.Core.FeatureFlagKeys.ReturnErrorOnExistingKeypair).Returns(false); // Act var result = await _sut.PostKeys(model); From bead4f1d5a3ac5b337eca748499e50d2825ee3a5 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Mon, 15 Dec 2025 15:19:17 -0500 Subject: [PATCH 16/47] validate and email on sso privisioning (#6734) --- .../Implementations/RegisterUserCommand.cs | 3 + .../Registration/RegisterUserCommandTests.cs | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index be85a858a3..4a0e9c2cf5 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -99,6 +99,9 @@ public class RegisterUserCommand : IRegisterUserCommand public async Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization) { + // Validate that the email domain is not blocked by another organization's policy + await ValidateEmailDomainNotBlockedAsync(user.Email, organization.Id); + var result = await _userService.CreateUserAsync(user); if (result == IdentityResult.Success) { diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 92a3f3fb10..ae669398c5 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1382,4 +1382,90 @@ public class RegisterUserCommandTests .Received(1) .SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_WithBlockedDomain_ThrowsException( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Email = "user@blocked-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", organization.Id) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_WithOwnClaimedDomain_Succeeds( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Email = "user@company-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + // Domain is claimed by THIS organization, so it should be allowed + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("company-domain.com", organization.Id) + .Returns(false); // Not blocked because organization.Id is excluded + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_WithNonClaimedDomain_Succeeds( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Email = "user@unclaimed-domain.com"; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation) + .Returns(true); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("unclaimed-domain.com", organization.Id) + .Returns(false); // Domain is not claimed by any org + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } } From e646b91a50720d27f5366ec03278881c0fc25df2 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 15 Dec 2025 15:40:00 -0600 Subject: [PATCH 17/47] [PM-27131] Auto confirm policy requirement (#6649) * Added Auto confirm policy enforcement requirement. Includes strict single org enforcement along with blocking provider users from joining orgs with auto confirm enabled. --- .../AdminConsole/Services/ProviderService.cs | 45 ++- .../Services/ProviderServiceTests.cs | 306 +++++++++++++++++- .../OrganizationUsers/AcceptOrgUserCommand.cs | 34 +- ...icallyConfirmOrganizationUsersValidator.cs | 57 ++-- .../AutoConfirmUser/Errors.cs | 7 +- .../ConfirmOrganizationUserCommand.cs | 33 +- .../v1/RestoreOrganizationUserCommand.cs | 23 +- .../CloudOrganizationSignUpCommand.cs | 17 +- .../InitPendingOrganizationCommand.cs | 21 +- .../SelfHostedOrganizationSignUpCommand.cs | 21 +- ...serConfirmationPolicyEnforcementRequest.cs | 44 +++ ...rConfirmationPolicyEnforcementValidator.cs | 49 +++ ...rConfirmationPolicyEnforcementValidator.cs | 28 ++ ...omaticUserConfirmationPolicyRequirement.cs | 48 +++ .../PolicyServiceCollectionExtensions.cs | 6 +- .../OrganizationUserPolicyDetailsFixtures.cs | 27 +- .../AcceptOrgUserCommandTests.cs | 104 +++++- ...yConfirmOrganizationUsersValidatorTests.cs | 295 +++++++---------- .../ConfirmOrganizationUserCommandTests.cs | 255 +++++++++++++++ ...irmationPolicyEnforcementValidatorTests.cs | 306 ++++++++++++++++++ 20 files changed, 1488 insertions(+), 238 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 3d18e95f7b..4e8a23cf4e 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -9,6 +9,9 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; @@ -60,6 +63,7 @@ public class ProviderService : IProviderService private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository, @@ -69,7 +73,8 @@ public class ProviderService : IProviderService ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService, IDataProtectorTokenFactory providerDeleteTokenDataFactory, IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient, - IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand) + IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand, + IPolicyRequirementQuery policyRequirementQuery) { _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; @@ -90,6 +95,7 @@ public class ProviderService : IProviderService _providerBillingService = providerBillingService; _pricingClient = pricingClient; _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; + _policyRequirementQuery = policyRequirementQuery; } public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) @@ -117,6 +123,18 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(ownerUserId); + + if (organizationAutoConfirmPolicyRequirement + .CannotCreateProvider()) + { + throw new BadRequestException(new UserCannotJoinProvider().Message); + } + } + var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); @@ -249,6 +267,18 @@ public class ProviderService : IProviderService throw new BadRequestException("User email does not match invite."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(user.Id); + + if (organizationAutoConfirmPolicyRequirement + .CannotJoinProvider()) + { + throw new BadRequestException(new UserCannotJoinProvider().Message); + } + } + providerUser.Status = ProviderUserStatusType.Accepted; providerUser.UserId = user.Id; providerUser.Email = null; @@ -294,6 +324,19 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid user."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery + .GetAsync(user.Id); + + if (organizationAutoConfirmPolicyRequirement + .CannotJoinProvider()) + { + result.Add(Tuple.Create(providerUser, new UserCannotJoinProvider().Message)); + continue; + } + } + providerUser.Status = ProviderUserStatusType.Confirmed; providerUser.Key = keys[providerUser.Id]; providerUser.Email = null; diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 11ffe115e2..7ec11894ad 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -1,12 +1,17 @@ using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; @@ -101,6 +106,57 @@ public class ProviderServiceTests .ReplaceAsync(Arg.Is(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key)); } + [Theory, BitAutoData] + public async Task CompleteSetupAsync_WithAutoConfirmEnabled_ThrowsUserCannotJoinProviderError(User user, Provider provider, + string key, + TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress, + [ProviderUser] ProviderUser providerUser, + SutProvider sutProvider) + { + providerUser.ProviderId = provider.Id; + providerUser.UserId = user.Id; + var userService = sutProvider.GetDependency(); + userService.GetUserByIdAsync(user.Id).Returns(user); + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); + + var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); + var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + var providerBillingService = sutProvider.GetDependency(); + + var customer = new Customer { Id = "customer_id" }; + providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer); + + var subscription = new Subscription { Id = "subscription_id" }; + providerBillingService.SetupSubscription(provider).Returns(subscription); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List { new() { OrganizationId = Guid.NewGuid(), IsProvider = false } }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + sutProvider.Create(); + + var token = protector.Protect( + $"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, + billingAddress)); + + Assert.Equal(new UserCannotJoinProvider().Message, exception.Message); + } + [Theory, BitAutoData] public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider sutProvider) { @@ -580,6 +636,132 @@ public class ProviderServiceTests Assert.Equal(user.Id, pu.UserId); } + [Theory, BitAutoData] + public async Task AcceptUserAsync_WithAutoConfirmEnabledAndPolicyExists_Throws( + [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser, + User user, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + + sutProvider.Create(); + + providerUser.Email = user.Email; + var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List + { + new() { OrganizationId = Guid.NewGuid(), IsProvider = false } + }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token)); + + Assert.Equal(new UserCannotJoinProvider().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_WithAutoConfirmEnabledButNoPolicyExists_Success( + [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser, + User user, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + sutProvider.Create(); + + providerUser.Email = user.Email; + var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement([]); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(policyRequirement); + + // Act + var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token); + + // Assert + Assert.Null(pu.Email); + Assert.Equal(ProviderUserStatusType.Accepted, pu.Status); + Assert.Equal(user.Id, pu.UserId); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_WithAutoConfirmDisabled_Success( + [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser, + User user, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(providerUser.Id) + .Returns(providerUser); + + var protector = DataProtectionProvider + .Create("ApplicationName") + .CreateProtector("ProviderServiceDataProtector"); + + sutProvider.GetDependency() + .CreateProtector("ProviderServiceDataProtector") + .Returns(protector); + sutProvider.Create(); + + providerUser.Email = user.Email; + var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + // Act + var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token); + + // Assert + Assert.Null(pu.Email); + Assert.Equal(ProviderUserStatusType.Accepted, pu.Status); + Assert.Equal(user.Id, pu.UserId); + + // Verify that policy check was never called when feature flag is disabled + await sutProvider.GetDependency() + .DidNotReceive() + .GetAsync(user.Id); + } + [Theory, BitAutoData] public async Task ConfirmUsersAsync_NoValid( [ProviderUser(ProviderUserStatusType.Invited)] ProviderUser pu1, @@ -626,13 +808,131 @@ public class ProviderServiceTests Assert.Equal("Invalid user.", result[2].Item2); } + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabledAndPolicyExists_ReturnsError( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyDetails = new List + { + new() { OrganizationId = Guid.NewGuid(), IsProvider = false } + }; + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + sutProvider.GetDependency() + .GetAsync(u1.Id) + .Returns(policyRequirement); + + var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key"); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id); + + // Assert + Assert.Single(result); + Assert.Equal(new UserCannotJoinProvider().Message, result[0].Item2); + + // Verify user was not confirmed + await providerUserRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabledButNoPolicyExists_Success( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(new List()); + sutProvider.GetDependency() + .GetAsync(u1.Id) + .Returns(policyRequirement); + + var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key"); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id); + + // Assert + Assert.Single(result); + Assert.Equal("", result[0].Item2); + + // Verify user was confirmed + await providerUserRepository.Received(1).ReplaceAsync(Arg.Is(pu => + pu.Status == ProviderUserStatusType.Confirmed)); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmDisabled_Success( + [ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1, + Provider provider, User confirmingUser, SutProvider sutProvider) + { + // Arrange + pu1.ProviderId = provider.Id; + pu1.UserId = u1.Id; + var providerUsers = new[] { pu1 }; + + var providerUserRepository = sutProvider.GetDependency(); + providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers); + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([u1]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key"); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id); + + // Assert + Assert.Single(result); + Assert.Equal("", result[0].Item2); + + // Verify user was confirmed + await providerUserRepository.Received(1).ReplaceAsync(Arg.Is(pu => + pu.Status == ProviderUserStatusType.Confirmed)); + + // Verify that policy check was never called when feature flag is disabled + await sutProvider.GetDependency() + .DidNotReceive() + .GetAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task SaveUserAsync_UserIdIsInvalid_Throws(ProviderUser providerUser, SutProvider sutProvider) { - providerUser.Id = default; - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUserAsync(providerUser, default)); + providerUser.Id = Guid.Empty; + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveUserAsync(providerUser, Guid.Empty)); Assert.Equal("Invite the user first.", exception.Message); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 63f177b3f3..c763cc0cc2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; @@ -34,6 +35,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; public AcceptOrgUserCommand( IDataProtectionProvider dataProtectionProvider, @@ -46,7 +48,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IFeatureService featureService, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) { // TODO: remove data protector when old token validation removed _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); @@ -60,6 +63,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _policyRequirementQuery = policyRequirementQuery; + _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -186,13 +190,19 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } } - // Enforce Single Organization Policy of organization user is trying to join var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(user.Id); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); + + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + await ValidateAutomaticUserConfirmationPolicyAsync(orgUser, allOrgUsers, user); + } + + // Enforce Single Organization Policy of organization user is trying to join var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited); - if (hasOtherOrgs && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) + if (allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId) + && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) { throw new BadRequestException("You may not join this organization until you leave or remove all other organizations."); } @@ -255,4 +265,20 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } } } + + private async Task ValidateAutomaticUserConfirmationPolicyAsync(OrganizationUser orgUser, + ICollection allOrgUsers, User user) + { + var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user))) + .Match( + error => error.Message, + _ => string.Empty + ); + + if (!string.IsNullOrEmpty(error)) + { + throw new BadRequestException(error); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs index 11b89de680..3375120516 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2; @@ -8,6 +9,7 @@ using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; @@ -16,6 +18,8 @@ public class AutomaticallyConfirmOrganizationUsersValidator( IOrganizationUserRepository organizationUserRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IPolicyRequirementQuery policyRequirementQuery, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, + IUserService userService, IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator { public async Task> ValidateAsync( @@ -61,7 +65,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator( return Invalid(request, new UserDoesNotHaveTwoFactorEnabled()); } - if (await OrganizationUserConformsToSingleOrgPolicyAsync(request) is { } error) + if (await OrganizationUserConformsToAutomaticUserConfirmationPolicyAsync(request) is { } error) { return Invalid(request, error); } @@ -69,10 +73,8 @@ public class AutomaticallyConfirmOrganizationUsersValidator( return Valid(request); } - private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync( - AutomaticallyConfirmOrganizationUserValidationRequest request) => - await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, - PolicyType.AutomaticUserConfirmation) is { Enabled: true } + private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => + await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true } && request.Organization is { UseAutomaticUserConfirmation: true }; private async Task OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) @@ -87,30 +89,37 @@ public class AutomaticallyConfirmOrganizationUsersValidator( .IsTwoFactorRequiredForOrganization(request.Organization!.Id); } - private async Task OrganizationUserConformsToSingleOrgPolicyAsync( + /// + /// Validates whether the specified organization user complies with the automatic user confirmation policy. + /// This includes checks across all organizations the user is associated with to ensure they meet the compliance criteria. + /// + /// We are not checking single organization policy compliance here because automatically confirm users policy enforces + /// a stricter version and applies to all users. If you are compliant with Auto Confirm, you'll be in compliance with + /// Single Org. + /// + /// + /// The request model encapsulates the current organization, the user being validated, and all organization users associated + /// with that user. + /// + /// + /// An if the user fails to meet the automatic user confirmation policy, or null if the validation succeeds. + /// + private async Task OrganizationUserConformsToAutomaticUserConfirmationPolicyAsync( AutomaticallyConfirmOrganizationUserValidationRequest request) { var allOrganizationUsersForUser = await organizationUserRepository .GetManyByUserAsync(request.OrganizationUser!.UserId!.Value); - if (allOrganizationUsersForUser.Count == 1) - { - return null; - } + var user = await userService.GetUserByIdAsync(request.OrganizationUser!.UserId!.Value); - var policyRequirement = await policyRequirementQuery - .GetAsync(request.OrganizationUser!.UserId!.Value); - - if (policyRequirement.IsSingleOrgEnabledForThisOrganization(request.Organization!.Id)) - { - return new OrganizationEnforcesSingleOrgPolicy(); - } - - if (policyRequirement.IsSingleOrgEnabledForOrganizationsOtherThan(request.Organization.Id)) - { - return new OtherOrganizationEnforcesSingleOrgPolicy(); - } - - return null; + return (await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest( + request.OrganizationId, + allOrganizationUsersForUser, + user))) + .Match( + error => error, + _ => null + ); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs index 1564daca6c..e65db00f73 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/Errors.cs @@ -8,6 +8,9 @@ public record UserIsNotUserType() : BadRequestError("Only organization users wit public record UserIsNotAccepted() : BadRequestError("Cannot confirm user that has not accepted the invitation."); public record OrganizationUserIdIsInvalid() : BadRequestError("Invalid organization user id."); public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled."); -public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations"); -public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it."); +public record UserCannotBelongToAnotherOrganization() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations"); +public record OtherOrganizationDoesNotAllowOtherMembership() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it."); public record AutomaticallyConfirmUsersPolicyIsNotEnabled() : BadRequestError("Cannot confirm this member because the Automatically Confirm Users policy is not enabled."); +public record ProviderUsersCannotJoin() : BadRequestError("An organization the user is a part of has enabled Automatic User Confirmation policy, and it does not support provider users joining."); +public record UserCannotJoinProvider() : BadRequestError("An organization the user is a part of has enabled Automatic User Confirmation policy, and it does not support the user joining a provider."); +public record CurrentOrganizationUserIsNotPresentInRequest() : BadRequestError("The current organization user does not exist in the request."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 2fbe6be5c6..b6b49e93e9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -33,6 +34,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly ICollectionRepository _collectionRepository; + private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, @@ -47,7 +49,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IDeviceRepository deviceRepository, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -62,6 +65,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _collectionRepository = collectionRepository; + _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, @@ -127,6 +131,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand var organization = await _organizationRepository.GetByIdAsync(organizationId); var allUsersOrgs = await _organizationUserRepository.GetManyByManyUsersAsync(validSelectedUserIds); + var users = await _userRepository.GetManyAsync(validSelectedUserIds); var usersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(validSelectedUserIds); @@ -188,6 +193,25 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled); var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); + + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationId, + userOrgs, + user))) + .Match( + error => new BadRequestException(error.Message), + _ => null + ); + + if (error is not null) + { + throw error; + } + } + var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); var otherSingleOrgPolicies = singleOrgPolicies.Where(p => p.OrganizationId != organizationId); @@ -267,8 +291,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var organizationDataOwnershipPolicy = - await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); + var organizationDataOwnershipPolicy = await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId)) { return; @@ -311,8 +334,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var policyEligibleOrganizationUserIds = - await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + var policyEligibleOrganizationUserIds = await _policyRequirementQuery + .GetManyByOrganizationIdAsync(organizationId); var eligibleOrganizationUserIds = confirmedOrganizationUsers .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 651a9225b4..ec42c8b402 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -29,7 +30,8 @@ public class RestoreOrganizationUserCommand( IUserRepository userRepository, IOrganizationService organizationService, IFeatureService featureService, - IPolicyRequirementQuery policyRequirementQuery) : IRestoreOrganizationUserCommand + IPolicyRequirementQuery policyRequirementQuery, + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) { @@ -300,6 +302,25 @@ public class RestoreOrganizationUserCommand( { throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); } + + if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var validationResult = await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, + allOrgUsers, + user!)); + + var badRequestException = validationResult.Match( + error => new BadRequestException(user.Email + + " is not compliant with the automatic user confirmation policy: " + + error.Message), + _ => null); + + if (badRequestException is not null) + { + throw badRequestException; + } + } } private async Task IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 154c3b7319..7f24c4acd7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; @@ -43,7 +45,9 @@ public class CloudOrganizationSignUpCommand( IPushNotificationService pushNotificationService, ICollectionRepository collectionRepository, IDeviceRepository deviceRepository, - IPricingClient pricingClient) : ICloudOrganizationSignUpCommand + IPricingClient pricingClient, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) : ICloudOrganizationSignUpCommand { public async Task SignUpOrganizationAsync(OrganizationSignup signup) { @@ -237,6 +241,17 @@ public class CloudOrganizationSignUpCommand( private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await policyRequirementQuery.GetAsync(ownerId); + + if (requirement.CannotCreateNewOrganization()) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); if (anySingleOrgPolicies) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 6474914b48..da678ece71 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -2,6 +2,8 @@ #nullable disable using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; @@ -28,6 +30,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private readonly IGlobalSettings _globalSettings; private readonly IPolicyService _policyService; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public InitPendingOrganizationCommand( IOrganizationService organizationService, @@ -37,7 +41,9 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand IDataProtectionProvider dataProtectionProvider, IGlobalSettings globalSettings, IPolicyService policyService, - IOrganizationUserRepository organizationUserRepository + IOrganizationUserRepository organizationUserRepository, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery ) { _organizationService = organizationService; @@ -48,6 +54,8 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand _globalSettings = globalSettings; _policyService = policyService; _organizationUserRepository = organizationUserRepository; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken) @@ -113,6 +121,17 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery.GetAsync(ownerId); + + if (requirement.CannotCreateNewOrganization()) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); if (anySingleOrgPolicies) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs index 6a7d068ae1..9abce991c3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; @@ -31,6 +33,8 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private readonly IPolicyService _policyService; private readonly IGlobalSettings _globalSettings; private readonly IStripePaymentService _paymentService; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public SelfHostedOrganizationSignUpCommand( IOrganizationRepository organizationRepository, @@ -44,7 +48,9 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp ILicensingService licensingService, IPolicyService policyService, IGlobalSettings globalSettings, - IStripePaymentService paymentService) + IStripePaymentService paymentService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -58,6 +64,8 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp _policyService = policyService; _globalSettings = globalSettings; _paymentService = paymentService; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( @@ -103,6 +111,17 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery.GetAsync(ownerId); + + if (requirement.CannotCreateNewOrganization()) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); if (anySingleOrgPolicies) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs new file mode 100644 index 0000000000..962da4bef7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementRequest.cs @@ -0,0 +1,44 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Request object for +/// +public record AutomaticUserConfirmationPolicyEnforcementRequest +{ + /// + /// Organization to be validated + /// + public Guid OrganizationId { get; } + + /// + /// All organization users that match the provided user. + /// + public ICollection AllOrganizationUsers { get; } + + /// + /// User associated with the organization user to be confirmed + /// + public User User { get; } + + /// + /// Request object for . + /// + /// + /// This record is used to encapsulate the data required for handling the automatic confirmation policy enforcement. + /// + /// The organization to be validated. + /// All organization users that match the provided user. + /// The user entity connecting all org users provided. + public AutomaticUserConfirmationPolicyEnforcementRequest( + Guid organizationId, + IEnumerable organizationUsers, + User user) + { + OrganizationId = organizationId; + AllOrganizationUsers = organizationUsers.ToArray(); + User = user; + } +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs new file mode 100644 index 0000000000..633b84d2b9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -0,0 +1,49 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +public class AutomaticUserConfirmationPolicyEnforcementValidator( + IPolicyRequirementQuery policyRequirementQuery, + IProviderUserRepository providerUserRepository) + : IAutomaticUserConfirmationPolicyEnforcementValidator +{ + public async Task> IsCompliantAsync( + AutomaticUserConfirmationPolicyEnforcementRequest request) + { + var automaticUserConfirmationPolicyRequirement = await policyRequirementQuery + .GetAsync(request.User.Id); + + var currentOrganizationUser = request.AllOrganizationUsers + .FirstOrDefault(x => x.OrganizationId == request.OrganizationId + && x.UserId == request.User.Id); + + if (currentOrganizationUser is null) + { + return Invalid(request, new CurrentOrganizationUserIsNotPresentInRequest()); + } + + if (automaticUserConfirmationPolicyRequirement.IsEnabled(request.OrganizationId)) + { + if ((await providerUserRepository.GetManyByUserAsync(request.User.Id)).Count != 0) + { + return Invalid(request, new ProviderUsersCannotJoin()); + } + + if (request.AllOrganizationUsers.Count > 1) + { + return Invalid(request, new UserCannotBelongToAnotherOrganization()); + } + } + + if (automaticUserConfirmationPolicyRequirement.IsEnabledForOrganizationsOtherThan(currentOrganizationUser.OrganizationId)) + { + return Invalid(request, new OtherOrganizationDoesNotAllowOtherMembership()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs new file mode 100644 index 0000000000..7bc1664140 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/IAutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Validation; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +/// +/// Used to enforce the Automatic User Confirmation policy. It uses the to retrieve +/// the . It is used to check to make sure the given user is +/// valid for the Automatic User Confirmation policy. It also validates that the given user is not a provider +/// or a member of another organization regardless of status or type. +/// +public interface IAutomaticUserConfirmationPolicyEnforcementValidator +{ + + /// + /// Checks if the given user is compliant with the Automatic User Confirmation policy. + /// + /// To be compliant, a user must + /// - not be a member of a provider + /// - not be a member of another organization + /// + /// + /// + /// This uses the validation result pattern to avoid throwing exceptions. + /// + /// A validation result with the error message if applicable. + Task> IsCompliantAsync(AutomaticUserConfirmationPolicyEnforcementRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs new file mode 100644 index 0000000000..3430f33a77 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -0,0 +1,48 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Represents the enforcement status of the Automatic User Confirmation policy. +/// +/// +/// The Automatic User Confirmation policy is enforced against all types of users regardless of status or type. +/// +/// Users cannot: +///
    +///
  • Be a member of another organization (similar to Single Organization Policy)
  • +///
  • Cannot be a provider
  • +///
+///
+/// Collection of policy details that apply to this user id +public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement +{ + public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); + + public bool CannotJoinProvider() => policyDetails.Any(); + + public bool CannotCreateProvider() => policyDetails.Any(); + + public bool CannotCreateNewOrganization() => policyDetails.Any(); + + public bool IsEnabled(Guid organizationId) => policyDetails.Any(p => p.OrganizationId == organizationId); + + public bool IsEnabledForOrganizationsOtherThan(Guid organizationId) => + policyDetails.Any(p => p.OrganizationId != organizationId); +} + +public class AutomaticUserConfirmationPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.AutomaticUserConfirmation; + + protected override IEnumerable ExemptRoles => []; + + protected override IEnumerable ExemptStatuses => []; + + protected override bool ExemptProviders => false; + + public override AutomaticUserConfirmationPolicyRequirement Create(IEnumerable policyDetails) => + new(policyDetails); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 272fd8cee4..f69935715d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; @@ -23,6 +24,8 @@ public static class PolicyServiceCollectionExtensions services.AddPolicyRequirements(); services.AddPolicySideEffects(); services.AddPolicyUpdateEvents(); + + services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] @@ -69,5 +72,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); services.AddScoped, MasterPasswordPolicyRequirementFactory>(); services.AddScoped, SingleOrganizationPolicyRequirementFactory>(); + services.AddScoped, AutomaticUserConfirmationPolicyRequirementFactory>(); } } diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs index 634b234e70..53511de550 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationUserPolicyDetailsFixtures.cs @@ -2,6 +2,7 @@ using AutoFixture; using AutoFixture.Xunit2; using Bit.Core.AdminConsole.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Test.AdminConsole.AutoFixture; @@ -9,10 +10,16 @@ namespace Bit.Core.Test.AdminConsole.AutoFixture; internal class OrganizationUserPolicyDetailsCustomization : ICustomization { public PolicyType Type { get; set; } + public OrganizationUserStatusType Status { get; set; } + public OrganizationUserType UserType { get; set; } + public bool IsProvider { get; set; } - public OrganizationUserPolicyDetailsCustomization(PolicyType type) + public OrganizationUserPolicyDetailsCustomization(PolicyType type, OrganizationUserStatusType status, OrganizationUserType userType, bool isProvider) { Type = type; + Status = status; + UserType = userType; + IsProvider = isProvider; } public void Customize(IFixture fixture) @@ -20,6 +27,9 @@ internal class OrganizationUserPolicyDetailsCustomization : ICustomization fixture.Customize(composer => composer .With(o => o.OrganizationId, Guid.NewGuid()) .With(o => o.PolicyType, Type) + .With(o => o.OrganizationUserStatus, Status) + .With(o => o.OrganizationUserType, UserType) + .With(o => o.IsProvider, IsProvider) .With(o => o.PolicyEnabled, true)); } } @@ -27,14 +37,25 @@ internal class OrganizationUserPolicyDetailsCustomization : ICustomization public class OrganizationUserPolicyDetailsAttribute : CustomizeAttribute { private readonly PolicyType _type; + private readonly OrganizationUserStatusType _status; + private readonly OrganizationUserType _userType; + private readonly bool _isProvider; - public OrganizationUserPolicyDetailsAttribute(PolicyType type) + public OrganizationUserPolicyDetailsAttribute(PolicyType type) : this(type, OrganizationUserStatusType.Accepted, OrganizationUserType.User, false) { _type = type; } + public OrganizationUserPolicyDetailsAttribute(PolicyType type, OrganizationUserStatusType status, OrganizationUserType userType, bool isProvider) + { + _type = type; + _status = status; + _userType = userType; + _isProvider = isProvider; + } + public override ICustomization GetCustomization(ParameterInfo parameter) { - return new OrganizationUserPolicyDetailsCustomization(_type); + return new OrganizationUserPolicyDetailsCustomization(_type, _status, _userType, _isProvider); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 540bac4d1c..82d4eceaed 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; @@ -24,6 +26,7 @@ using Bit.Test.Common.Fakes; using Microsoft.AspNetCore.DataProtection; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.OrganizationFeatures.OrganizationUsers; @@ -673,6 +676,79 @@ public class AcceptOrgUserCommandTests Assert.Equal("User not found within organization.", exception.Message); } + // Auto-confirm policy validation tests -------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithAutoConfirmIsNotEnabled_DoesNotCheckCompliance( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .IsCompliantAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithUserThatIsCompliantWithAutoConfirm_AcceptsUser( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Mock auto-confirm enforcement query to return valid (no auto-confirm restrictions) + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(ou => ou.Id == orgUser.Id && ou.Status == OrganizationUserStatusType.Accepted)); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithAutoConfirmIsEnabledAndFailsCompliance_ThrowsBadRequestException( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails, + OrganizationUser otherOrgUser) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), + new UserCannotBelongToAnotherOrganization())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + // Should get auto-confirm error + Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + } + // Private helpers ------------------------------------------------------------------------------------------------- /// @@ -716,7 +792,7 @@ public class AcceptOrgUserCommandTests /// - Provides mock data for an admin to validate email functionality. /// - Returns the corresponding organization for the given org ID. /// - private void SetupCommonAcceptOrgUserMocks(SutProvider sutProvider, User user, + private static void SetupCommonAcceptOrgUserMocks(SutProvider sutProvider, User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) { @@ -729,18 +805,12 @@ public class AcceptOrgUserCommandTests // User is not part of any other orgs sutProvider.GetDependency() .GetManyByUserAsync(user.Id) - .Returns( - Task.FromResult>(new List()) - ); + .Returns([]); // Org they are trying to join does not have single org policy sutProvider.GetDependency() .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited) - .Returns( - Task.FromResult>( - new List() - ) - ); + .Returns([]); // User is not part of any organization that applies the single org policy sutProvider.GetDependency() @@ -750,20 +820,24 @@ public class AcceptOrgUserCommandTests // Org does not require 2FA sutProvider.GetDependency().GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited) - .Returns(Task.FromResult>( - new List())); + .Returns([]); // Provide at least 1 admin to test email functionality sutProvider.GetDependency() .GetManyByMinimumRoleAsync(orgUser.OrganizationId, OrganizationUserType.Admin) - .Returns(Task.FromResult>( - new List() { adminUserDetails } - )); + .Returns([adminUserDetails]); // Return org sutProvider.GetDependency() .GetByIdAsync(org.Id) - .Returns(Task.FromResult(org)); + .Returns(org); + + // Auto-confirm enforcement query returns valid by default (no restrictions) + var request = new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user); + + sutProvider.GetDependency() + .IsCompliantAsync(request) + .Returns(Valid(request)); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs index eb377a8d08..c3fb52ecbe 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -12,6 +13,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; @@ -19,6 +21,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUsers; @@ -116,11 +119,11 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true, planType: PlanType.EnterpriseAnnually)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -140,12 +143,23 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); + .Returns([(user.Id, true)]); sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -319,11 +333,11 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -343,12 +357,24 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); + .Returns([(user.Id, true)]); sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + + // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -362,11 +388,11 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -386,16 +412,28 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, false)]); + .Returns([(user.Id, false)]); sutProvider.GetDependency() - .GetAsync(userId) + .GetAsync(user.Id) .Returns(new RequireTwoFactorPolicyRequirement([])); // No 2FA policy sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + + // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -403,128 +441,17 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Assert.True(result.IsValid); } - [Theory] - [BitAutoData] - public async Task ValidateAsync_UserInMultipleOrgs_WithSingleOrgPolicyOnThisOrg_ReturnsError( - SutProvider sutProvider, - [Organization(useAutomaticUserConfirmation: true)] Organization organization, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - OrganizationUser otherOrgUser, - Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) - { - // Arrange - organizationUser.UserId = userId; - organizationUser.OrganizationId = organization.Id; - - var request = new AutomaticallyConfirmOrganizationUserValidationRequest - { - PerformedBy = Substitute.For(), - DefaultUserCollectionName = "test-collection", - OrganizationUser = organizationUser, - OrganizationUserId = organizationUser.Id, - Organization = organization, - OrganizationId = organization.Id, - Key = "test-key" - }; - - var singleOrgPolicyDetails = new PolicyDetails - { - OrganizationId = organization.Id, - PolicyType = PolicyType.SingleOrg - }; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns(autoConfirmPolicy); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns([organizationUser, otherOrgUser]); - - sutProvider.GetDependency() - .GetAsync(userId) - .Returns(new SingleOrganizationPolicyRequirement([singleOrgPolicyDetails])); - - // Act - var result = await sutProvider.Sut.ValidateAsync(request); - - // Assert - Assert.True(result.IsError); - Assert.IsType(result.AsError); - } - - [Theory] - [BitAutoData] - public async Task ValidateAsync_UserInMultipleOrgs_WithSingleOrgPolicyOnOtherOrg_ReturnsError( - SutProvider sutProvider, - [Organization(useAutomaticUserConfirmation: true)] Organization organization, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - OrganizationUser otherOrgUser, - Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) - { - // Arrange - organizationUser.UserId = userId; - organizationUser.OrganizationId = organization.Id; - - var request = new AutomaticallyConfirmOrganizationUserValidationRequest - { - PerformedBy = Substitute.For(), - DefaultUserCollectionName = "test-collection", - OrganizationUser = organizationUser, - OrganizationUserId = organizationUser.Id, - Organization = organization, - OrganizationId = organization.Id, - Key = "test-key" - }; - - var otherOrgId = Guid.NewGuid(); // Different org - var singleOrgPolicyDetails = new PolicyDetails - { - OrganizationId = otherOrgId, - PolicyType = PolicyType.SingleOrg, - }; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns(autoConfirmPolicy); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns([organizationUser, otherOrgUser]); - - sutProvider.GetDependency() - .GetAsync(userId) - .Returns(new SingleOrganizationPolicyRequirement([singleOrgPolicyDetails])); - - // Act - var result = await sutProvider.Sut.ValidateAsync(request); - - // Assert - Assert.True(result.IsError); - Assert.IsType(result.AsError); - } - [Theory] [BitAutoData] public async Task ValidateAsync_UserInSingleOrg_ReturnsValidResult( SutProvider sutProvider, [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId, + User user, [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) { // Arrange - organizationUser.UserId = userId; + organizationUser.UserId = user.Id; organizationUser.OrganizationId = organization.Id; var request = new AutomaticallyConfirmOrganizationUserValidationRequest @@ -544,61 +471,22 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); + .Returns([(user.Id, true)]); sutProvider.GetDependency() - .GetManyByUserAsync(userId) + .GetManyByUserAsync(user.Id) .Returns([organizationUser]); // Single org - // Act - var result = await sutProvider.Sut.ValidateAsync(request); + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); - // Assert - Assert.True(result.IsValid); - } - - [Theory] - [BitAutoData] - public async Task ValidateAsync_UserInMultipleOrgs_WithNoSingleOrgPolicy_ReturnsValidResult( - SutProvider sutProvider, - [Organization(useAutomaticUserConfirmation: true)] Organization organization, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - OrganizationUser otherOrgUser, - Guid userId, - Policy autoConfirmPolicy) - { - // Arrange - organizationUser.UserId = userId; - organizationUser.OrganizationId = organization.Id; - autoConfirmPolicy.Type = PolicyType.AutomaticUserConfirmation; - autoConfirmPolicy.Enabled = true; - - var request = new AutomaticallyConfirmOrganizationUserValidationRequest - { - PerformedBy = Substitute.For(), - DefaultUserCollectionName = "test-collection", - OrganizationUser = organizationUser, - OrganizationUserId = organizationUser.Id, - Organization = organization, - OrganizationId = organization.Id, - Key = "test-key" - }; - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns(autoConfirmPolicy); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns([(userId, true)]); - - sutProvider.GetDependency() - .GetManyByUserAsync(userId) - .Returns([organizationUser, otherOrgUser]); - - sutProvider.GetDependency() - .GetAsync(userId) - .Returns(new SingleOrganizationPolicyRequirement([])); + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); // Act var result = await sutProvider.Sut.ValidateAsync(request); @@ -693,4 +581,59 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Assert.True(result.IsError); Assert.IsType(result.AsError); } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithNonProviderUser_ReturnsValidResult( + SutProvider sutProvider, + [Organization(useAutomaticUserConfirmation: true)] Organization organization, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, + User user, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + { + // Arrange + organizationUser.UserId = user.Id; + organizationUser.OrganizationId = organization.Id; + + var request = new AutomaticallyConfirmOrganizationUserValidationRequest + { + PerformedBy = Substitute.For(), + DefaultUserCollectionName = "test-collection", + OrganizationUser = organizationUser, + OrganizationUserId = organizationUser.Id, + Organization = organization, + OrganizationId = organization.Id, + Key = "test-key" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + .Returns(autoConfirmPolicy); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns([(user.Id, true)]); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([organizationUser]); + + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, + [organizationUser], + user))); + + + // Act + var result = await sutProvider.Sut.ValidateAsync(request); + + // Assert + Assert.True(result.IsValid); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 86b068b88f..5528ecb2a2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -2,7 +2,9 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -21,6 +23,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; @@ -559,4 +562,256 @@ public class ConfirmOrganizationUserCommandTests .DidNotReceive() .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserBelongsToAnotherOrg_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser otherOrgUser, string key, SutProvider sutProvider) + { + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); // Different org + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.Id, [orgUser, otherOrgUser], user), + new UserCannotBelongToAnotherOrganization())); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmEnabledForOtherOrg_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser otherOrgUser, string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), + new OtherOrganizationDoesNotAllowOtherMembership())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserIsProvider_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user), + new ProviderUsersCannotJoin())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new ProviderUsersCannotJoin().Message, exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmNotApplicable_Succeeds( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); + await sutProvider.GetDependency() + .Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmValidationBeforeSingleOrgPolicy_ChecksAutoConfirmFirst( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + OrganizationUser otherOrgUser, + [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + string key, SutProvider sutProvider) + { + // Arrange - Setup conditions that would fail BOTH auto-confirm AND single org policy + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser, otherOrgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + singleOrgPolicy.OrganizationId = org.Id; + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) + .Returns([singleOrgPolicy]); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), + new UserCannotBelongToAnotherOrganization())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + + Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + Assert.NotEqual("Cannot confirm this member to the organization until they leave or remove all other organizations.", + exception.Message); + } + + [Theory, BitAutoData] + public async Task ConfirmUsersAsync_WithAutoConfirmEnabled_MixedResults( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, + OrganizationUser otherOrgUser, User user1, User user2, User user3, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + orgUser3.UserId = user3.Id; + otherOrgUser.UserId = user3.Id; + otherOrgUser.OrganizationId = Guid.NewGuid(); + + var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs(orgUsers); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([user1, user2, user3]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser1, orgUser2, orgUser3, otherOrgUser]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Is(r => r.User.Id == user1.Id)) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser1], user1))); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Is(r => r.User.Id == user2.Id)) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser2], user2))); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Is(r => r.User.Id == user3.Id)) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser3, otherOrgUser], user3), + new OtherOrganizationDoesNotAllowOtherMembership())); + + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); + + // Act + var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); + + // Assert + Assert.Equal(3, result.Count); + Assert.Empty(result[0].Item2); + Assert.Empty(result[1].Item2); + Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, result[2].Item2); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs new file mode 100644 index 0000000000..f2e6adbfa9 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidatorTests.cs @@ -0,0 +1,306 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; + +[SutProviderCustomize] +public class AutomaticUserConfirmationPolicyEnforcementValidatorTests +{ + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledAndUserIsProviderMember_ReturnsProviderUsersCannotJoinError( + SutProvider sutProvider, + OrganizationUser organizationUser, + ProviderUser providerUser, + User user) + { + // Arrange + organizationUser.UserId = providerUser.UserId = user.Id; + + var policyDetails = new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation + }; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledOnOtherOrganization_ReturnsOtherOrganizationDoesNotAllowOtherMembershipError( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrganizationUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + otherOrganizationUser.UserId = user.Id; + + var otherOrgId = Guid.NewGuid(); + var policyDetails = new PolicyDetails + { + OrganizationId = otherOrgId, // Different from organizationUser.OrganizationId + PolicyType = PolicyType.AutomaticUserConfirmation + }; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrganizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyDisabledUserIsAMemberOfAnotherOrgReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrgUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledUserIsAMemberOfAnotherOrg_ReturnsCannotBeMemberOfAnotherOrgError( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + otherOrgUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrgUser], + user); + + var policyDetails = new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation + }; + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyEnabledAndChecksConditionsInCorrectOrder_ReturnsFirstFailure( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + ProviderUser providerUser, + User user) + { + // Arrange + var policyDetails = new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserId = organizationUser.Id + }; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser, otherOrgUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([policyDetails])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyIsEnabledNoOtherOrganizationsAndNotAProvider_ReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + User user) + { + // Arrange + organizationUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([ + new PolicyDetails + { + OrganizationUserId = organizationUser.Id, + OrganizationId = organizationUser.OrganizationId, + PolicyType = PolicyType.AutomaticUserConfirmation, + } + ])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyDisabledForCurrentAndOtherOrg_ReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + User user) + { + // Arrange + otherOrgUser.UserId = organizationUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } + + [Theory] + [BitAutoData] + public async Task IsCompliantAsync_WithPolicyDisabledForCurrentAndOtherOrgAndIsProvider_ReturnsValid( + SutProvider sutProvider, + OrganizationUser organizationUser, + OrganizationUser otherOrgUser, + ProviderUser providerUser, + User user) + { + // Arrange + providerUser.UserId = otherOrgUser.UserId = organizationUser.UserId = user.Id; + + var request = new AutomaticUserConfirmationPolicyEnforcementRequest( + organizationUser.OrganizationId, + [organizationUser], + user); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.IsCompliantAsync(request); + + // Assert + Assert.True(result.IsValid); + } +} From f7c615cc01e1c1635da212cd16c7a2b8df594962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:31:56 +0000 Subject: [PATCH 18/47] [PM-21411] Refactor interface for determining premium status and features (#6688) * Removed 2FA user interface from premium method signatures * Added some more comments for clarity and small touchups. * Add PremiumAccessCacheCheck feature flag to Constants.cs * Add IPremiumAccessQuery interface and PremiumAccessQuery implementation for checking user premium access status * Add unit tests for PremiumAccessQuery to validate user premium access logic * Add XML documentation to Premium in OrganizationUserUserDetails and User classes * Add PremiumAccessQueries to UserServiceCollectionExtensions * Refactor TwoFactorIsEnabledQuery to incorporate PremiumAccessQuery and feature flag for premium access checks. Enhanced user premium status retrieval logic and improved handling of user details based on feature flag state. * Mark methods in IUserRepository and IUserService as obsolete, directing users to new methods in IPremiumAccessQuery for premium access checks. * Rename CanAccessPremiumBulkAsync to CanAccessPremiumAsync in IPremiumAccessQuery * Update TwoFactorIsEnabledQuery to use CanAccessPremiumAsync for premium status checks * Refactor TwoFactorIsEnabledQuery to introduce VNextAsync methods for improved premium access checks and user detail handling. Removed obsolete feature service dependency and enhanced test coverage for new functionality. * Refactor IPremiumAccessQuery and PremiumAccessQuery to remove the overloaded CanAccessPremiumAsync method. Update related methods to streamline premium access checks using the User object directly. Enhance test coverage by removing obsolete tests and ensuring proper functionality with the new method signatures. * Add new sync static method to determine if TwoFactor is enabled * Enhance XML documentation for Premium property in OrganizationUserUserDetails and User classes to clarify its usage and limitations regarding personal and organizational premium access. * Refactor IPremiumAccessQuery and PremiumAccessQuery to replace User parameter with Guid for user ID in CanAccessPremiumAsync methods. Update related methods and tests to streamline premium access checks and improve clarity in method signatures. * Update feature flag references in IUserRepository and IUserService to use 'PremiumAccessQuery' instead of 'PremiumAccessCacheCheck'. Adjust related XML documentation for clarity on premium access methods. * Rename IPremiumAccessQuery to IHasPremiumAccessQuery and move to Billing owned folder * Remove unnecessary whitespace from IHasPremiumAccessQuery interface. * Refactor HasPremiumAccessQuery to throw NotFoundException for null users * Add NotFoundException handling in HasPremiumAccessQuery for mismatched user counts * Refactor TwoFactorIsEnabledQuery to optimize premium access checks and improve two-factor provider handling. Introduced bulk fetching of premium status for users with only premium providers and streamlined the logic for determining if two-factor authentication is enabled. * Refactor TwoFactorIsEnabledQueryTests to enhance clarity and optimize test scenarios. Consolidated test cases for two-factor authentication, improved naming conventions, and ensured premium access checks are only performed when necessary. * Add UserPremiumAccess model to represent user premium access status from personal subscriptions and memberships * Add User_ReadPremiumAccessByIds stored procedure and UserPremiumAccessView view to enhance premium access retrieval. Updated Organization table index to include UsersGetPremium for optimized queries. * Add SQL migration script * Add premium access retrieval methods to IUserRepository and implementations in UserRepository classes. Introduced GetPremiumAccessByIdsAsync and GetPremiumAccessAsync methods to fetch premium status for multiple users and a single user, respectively. Updated using directives to include necessary models. * Refactor HasPremiumAccessQuery and IHasPremiumAccessQuery to streamline premium access checks. Updated method names for clarity and improved documentation. Adjusted test cases to reflect changes in user premium access retrieval logic. * Update IUserRepository to reflect new method names for premium access retrieval. Changed obsolete method messages to point to GetPremiumAccessByIdsAsync and GetPremiumAccessAsync. Added internal use notes for IHasPremiumAccessQuery. Improved documentation for clarity. * Refactor TwoFactorIsEnabledQuery to utilize IFeatureService for premium access checks. * Enhance EF UserRepository to improve premium access retrieval by including related organization data. * Add unit tests for premium access retrieval in UserRepositoryTests. * Optimize HasPremiumAccessQuery to eliminate duplicate user IDs before checking premium access. Updated logic to ensure accurate comparison of premium users against distinct user IDs. * Refactor TwoFactorIsEnabledQuery to improve handling of users without two-factor providers. Added early exit for users lacking providers and streamlined premium status checks for enabled two-factor authentication. * Update HasPremiumAccessQueryTests to use simplified exception handling and improve test clarity * Replaced fully qualified exception references with simplified ones. * Refactored test setup to use individual user variables for better readability. * Ensured assertions reflect the updated user variable structure. * Enhance TwoFactorIsEnabledQuery to throw NotFoundException for non-existent users * Updated TwoFactorIsEnabledQuery to throw NotFoundException when a user is not found instead of returning false. * Added a new unit test to verify that the NotFoundException is thrown when a user is not found while premium access query is enabled. * Move premium access query to Billing owned ServiceCollectionExtensions * Refactor IUserService to enhance premium access checks * Updated CanAccessPremium and HasPremiumFromOrganization methods to clarify usage with the new premium access query. * Integrated IHasPremiumAccessQuery into UserService for improved premium access handling based on feature flag. * Adjusted method documentation to reflect changes in premium access logic. * Update IUserRepository to clarify usage of premium access methods * Modified Obsolete attribute messages for GetManyWithCalculatedPremiumAsync and GetCalculatedPremiumAsync to indicate that callers should use the new methods when the 'PremiumAccessQuery' feature flag is enabled. * Enhanced documentation to improve clarity regarding premium access handling. * Update IUserRepository and IUserService to clarify deprecation of premium access methods * Modified Obsolete attribute messages for GetManyWithCalculatedPremiumAsync and GetCalculatedPremiumAsync in IUserRepository to indicate these methods will be removed in a future version. * Updated Obsolete attribute message for HasPremiumFromOrganization in IUserService to reflect the same deprecation notice. * Refactor TwoFactorIsEnabledQuery to streamline user ID retrieval * Consolidated user ID retrieval logic to avoid redundancy. * Ensured consistent handling of user ID checks for premium access queries. * Improved code readability by reducing duplicate code blocks. * Rename migration script to fix the date * Update migration script to create the index with DROP_EXISTING = ON * Refactor UserPremiumAccessView to use LEFT JOINs and GROUP BY for improved performance and clarity * Update HasPremiumAccessQueryTests to return null for GetPremiumAccessAsync instead of throwing NotFoundException * Add unit tests for premium access scenarios in UserRepositoryTests - Implement tests for GetPremiumAccessAsync to cover various user and organization premium access combinations. - Validate behavior when users belong to multiple organizations, including cases with and without premium access. - Update email generation for user creation to ensure uniqueness without specific prefixes. - Enhance assertions to verify expected premium access results across different test cases. * Bump date on migration script * Update OrganizationEntityTypeConfiguration to include UsersGetPremium in index properties * Add migration scripts for OrganizationUsersGetPremiumIndex across MySQL, PostgreSQL, and SQLite - Introduced new migration files to create the OrganizationUsersGetPremiumIndex. - Updated the DatabaseContextModelSnapshot to include UsersGetPremium in index properties for all database types. - Ensured consistency in index creation across different database implementations. --------- Co-authored-by: Todd Martin Co-authored-by: Patrick Pimentel --- .../OrganizationUserUserDetails.cs | 6 + .../TwoFactorAuth/TwoFactorIsEnabledQuery.cs | 163 +- .../Extensions/ServiceCollectionExtensions.cs | 7 + .../Premium/Models/UserPremiumAccess.cs | 29 + .../Premium/Queries/HasPremiumAccessQuery.cs | 49 + .../Premium/Queries/IHasPremiumAccessQuery.cs | 30 + src/Core/Constants.cs | 1 + src/Core/Entities/User.cs | 5 + src/Core/Repositories/IUserRepository.cs | 19 +- src/Core/Services/IUserService.cs | 3 +- .../Services/Implementations/UserService.cs | 16 +- .../Repositories/UserRepository.cs | 20 + .../OrganizationEntityTypeConfiguration.cs | 2 +- .../Repositories/UserRepository.cs | 31 + .../User_ReadPremiumAccessByIds.sql | 15 + src/Sql/dbo/Tables/Organization.sql | 2 +- src/Sql/dbo/Views/UserPremiumAccessView.sql | 21 + .../TwoFactorIsEnabledQueryTests.cs | 279 +- .../Queries/HasPremiumAccessQueryTests.cs | 234 ++ .../Auth/Repositories/UserRepositoryTests.cs | 321 ++ .../2025-12-12_00_UserPremiumAccessView.sql | 60 + ...ganizationUsersGetPremiumIndex.Designer.cs | 3443 ++++++++++++++++ ...171212_OrganizationUsersGetPremiumIndex.cs | 21 + .../DatabaseContextModelSnapshot.cs | 2 +- ...ganizationUsersGetPremiumIndex.Designer.cs | 3449 +++++++++++++++++ ...171204_OrganizationUsersGetPremiumIndex.cs | 37 + .../DatabaseContextModelSnapshot.cs | 2 +- ...ganizationUsersGetPremiumIndex.Designer.cs | 3432 ++++++++++++++++ ...171156_OrganizationUsersGetPremiumIndex.cs | 21 + .../DatabaseContextModelSnapshot.cs | 2 +- 30 files changed, 11701 insertions(+), 21 deletions(-) create mode 100644 src/Core/Billing/Premium/Models/UserPremiumAccess.cs create mode 100644 src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs create mode 100644 src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs create mode 100644 src/Sql/dbo/Stored Procedures/User_ReadPremiumAccessByIds.sql create mode 100644 src/Sql/dbo/Views/UserPremiumAccessView.sql create mode 100644 test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs create mode 100644 util/Migrator/DbScripts/2025-12-12_00_UserPremiumAccessView.sql create mode 100644 util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.cs create mode 100644 util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.cs create mode 100644 util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.cs diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 00bac01f76..00ba706a41 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -20,6 +20,12 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I public string Email { get; set; } public string AvatarColor { get; set; } public string TwoFactorProviders { get; set; } + /// + /// Indicates whether the user has a personal premium subscription. + /// Does not include premium access from organizations - + /// do not use this to check whether the user can access premium features. + /// Null when the organization user is in Invited status (UserId is null). + /// public bool? Premium { get; set; } public OrganizationUserStatusType Status { get; set; } public OrganizationUserType Type { get; set; } diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index cc86d3d71d..e6c0c1444a 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -4,16 +4,37 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Services; namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth; -public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFactorIsEnabledQuery +public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery { - private readonly IUserRepository _userRepository = userRepository; + private readonly IUserRepository _userRepository; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; + private readonly IFeatureService _featureService; + + public TwoFactorIsEnabledQuery( + IUserRepository userRepository, + IHasPremiumAccessQuery hasPremiumAccessQuery, + IFeatureService featureService) + { + _userRepository = userRepository; + _hasPremiumAccessQuery = hasPremiumAccessQuery; + _featureService = featureService; + } public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds) { + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + return await TwoFactorIsEnabledVNextAsync(userIds); + } + var result = new List<(Guid userId, bool hasTwoFactor)>(); if (userIds == null || !userIds.Any()) { @@ -36,6 +57,11 @@ public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFacto public async Task> TwoFactorIsEnabledAsync(IEnumerable users) where T : ITwoFactorProvidersUser { + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + return await TwoFactorIsEnabledVNextAsync(users); + } + var userIds = users .Select(u => u.GetUserId()) .Where(u => u.HasValue) @@ -71,13 +97,134 @@ public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFacto return false; } + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + var userEntity = user as User ?? await _userRepository.GetByIdAsync(userId.Value); + if (userEntity == null) + { + throw new NotFoundException(); + } + + return await TwoFactorIsEnabledVNextAsync(userEntity); + } + return await TwoFactorEnabledAsync( - user.GetTwoFactorProviders(), - async () => - { - var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); - return calcUser?.HasPremiumAccess ?? false; - }); + user.GetTwoFactorProviders(), + async () => + { + var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value); + return calcUser?.HasPremiumAccess ?? false; + }); + } + + private async Task> TwoFactorIsEnabledVNextAsync(IEnumerable userIds) + { + var result = new List<(Guid userId, bool hasTwoFactor)>(); + if (userIds == null || !userIds.Any()) + { + return result; + } + + var users = await _userRepository.GetManyAsync([.. userIds]); + + // Get enabled providers for each user + var usersTwoFactorProvidersMap = users.ToDictionary(u => u.Id, GetEnabledTwoFactorProviders); + + // Bulk fetch premium status only for users who need it (those with only premium providers) + var userIdsNeedingPremium = usersTwoFactorProvidersMap + .Where(kvp => kvp.Value.Any() && kvp.Value.All(TwoFactorProvider.RequiresPremium)) + .Select(kvp => kvp.Key) + .ToList(); + + var premiumStatusMap = userIdsNeedingPremium.Count > 0 + ? await _hasPremiumAccessQuery.HasPremiumAccessAsync(userIdsNeedingPremium) + : new Dictionary(); + + foreach (var user in users) + { + var userTwoFactorProviders = usersTwoFactorProvidersMap[user.Id]; + + if (!userTwoFactorProviders.Any()) + { + result.Add((user.Id, false)); + continue; + } + + // User has providers. If they're in the premium check map, verify premium status + var twoFactorIsEnabled = !premiumStatusMap.TryGetValue(user.Id, out var hasPremium) || hasPremium; + result.Add((user.Id, twoFactorIsEnabled)); + } + + return result; + } + + private async Task> TwoFactorIsEnabledVNextAsync(IEnumerable users) + where T : ITwoFactorProvidersUser + { + var userIds = users + .Select(u => u.GetUserId()) + .Where(u => u.HasValue) + .Select(u => u.Value) + .ToList(); + + var twoFactorResults = await TwoFactorIsEnabledVNextAsync(userIds); + + var result = new List<(T user, bool twoFactorIsEnabled)>(); + + foreach (var user in users) + { + var userId = user.GetUserId(); + if (userId.HasValue) + { + var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled; + result.Add((user, hasTwoFactor)); + } + else + { + result.Add((user, false)); + } + } + + return result; + } + + private async Task TwoFactorIsEnabledVNextAsync(User user) + { + var enabledProviders = GetEnabledTwoFactorProviders(user); + + if (!enabledProviders.Any()) + { + return false; + } + + // If all providers require premium, check if user has premium access + if (enabledProviders.All(TwoFactorProvider.RequiresPremium)) + { + return await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id); + } + + // User has at least one non-premium provider + return true; + } + + /// + /// Gets all enabled two-factor provider types for a user. + /// + /// user with two factor providers + /// list of enabled provider types + private static IList GetEnabledTwoFactorProviders(User user) + { + var providers = user.GetTwoFactorProviders(); + + if (providers == null || providers.Count == 0) + { + return Array.Empty(); + } + + // TODO: PM-21210: In practice we don't save disabled providers to the database, worth looking into. + return (from provider in providers + where provider.Value?.Enabled ?? false + select provider.Key).ToList(); } /// diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d6593f5365..5ceefed603 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Payment; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -31,6 +32,7 @@ public static class ServiceCollectionExtensions services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); + services.AddPremiumQueries(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -50,4 +52,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddTransient(); } + + private static void AddPremiumQueries(this IServiceCollection services) + { + services.AddScoped(); + } } diff --git a/src/Core/Billing/Premium/Models/UserPremiumAccess.cs b/src/Core/Billing/Premium/Models/UserPremiumAccess.cs new file mode 100644 index 0000000000..639d175d25 --- /dev/null +++ b/src/Core/Billing/Premium/Models/UserPremiumAccess.cs @@ -0,0 +1,29 @@ +namespace Bit.Core.Billing.Premium.Models; + +/// +/// Represents user premium access status from personal subscriptions and organization memberships. +/// +public class UserPremiumAccess +{ + /// + /// The unique identifier for the user. + /// + public Guid Id { get; set; } + + /// + /// Indicates whether the user has a personal premium subscription. + /// This does NOT include premium access from organizations. + /// + public bool PersonalPremium { get; set; } + + /// + /// Indicates whether the user has premium access through any organization membership. + /// This is true if the user is a member of at least one enabled organization that grants premium access to users. + /// + public bool OrganizationPremium { get; set; } + + /// + /// Indicates whether the user has premium access from any source (personal subscription or organization). + /// + public bool HasPremiumAccess => PersonalPremium || OrganizationPremium; +} diff --git a/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs new file mode 100644 index 0000000000..e90710a9b3 --- /dev/null +++ b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs @@ -0,0 +1,49 @@ +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Core.Billing.Premium.Queries; + +public class HasPremiumAccessQuery : IHasPremiumAccessQuery +{ + private readonly IUserRepository _userRepository; + + public HasPremiumAccessQuery(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task HasPremiumAccessAsync(Guid userId) + { + var user = await _userRepository.GetPremiumAccessAsync(userId); + if (user == null) + { + throw new NotFoundException(); + } + + return user.HasPremiumAccess; + } + + public async Task> HasPremiumAccessAsync(IEnumerable userIds) + { + var distinctUserIds = userIds.Distinct().ToList(); + var usersWithPremium = await _userRepository.GetPremiumAccessByIdsAsync(distinctUserIds); + + if (usersWithPremium.Count() != distinctUserIds.Count) + { + throw new NotFoundException(); + } + + return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess); + } + + public async Task HasPremiumFromOrganizationAsync(Guid userId) + { + var user = await _userRepository.GetPremiumAccessAsync(userId); + if (user == null) + { + throw new NotFoundException(); + } + + return user.OrganizationPremium; + } +} diff --git a/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs new file mode 100644 index 0000000000..e5545b1ade --- /dev/null +++ b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs @@ -0,0 +1,30 @@ +namespace Bit.Core.Billing.Premium.Queries; + +/// +/// Centralized query for checking if users have premium access through personal subscriptions or organizations. +/// Note: Different from User.Premium which only checks personal subscriptions. +/// +public interface IHasPremiumAccessQuery +{ + /// + /// Checks if a user has premium access (personal or organization). + /// + /// The user ID to check + /// True if user can access premium features + Task HasPremiumAccessAsync(Guid userId); + + /// + /// Checks premium access for multiple users. + /// + /// The user IDs to check + /// Dictionary mapping user IDs to their premium access status + Task> HasPremiumAccessAsync(IEnumerable userIds); + + /// + /// Checks if a user belongs to any organization that grants premium (enabled org with UsersGetPremium). + /// Returns true regardless of personal subscription. Useful for UI decisions like showing subscription options. + /// + /// The user ID to check + /// True if user is in any organization that grants premium + Task HasPremiumFromOrganizationAsync(Guid userId); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cf3f40ec80..95ab009722 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -143,6 +143,7 @@ public static class FeatureFlagKeys public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2"; + public const string PremiumAccessQuery = "pm-21411-premium-access-query"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 1ca6606779..669e32bcbe 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -69,6 +69,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac /// The security state is a signed object attesting to the version of the user's account. /// public string? SecurityState { get; set; } + /// + /// Indicates whether the user has a personal premium subscription. + /// Does not include premium access from organizations - + /// do not use this to check whether the user can access premium features. + /// public bool Premium { get; set; } public DateTime? PremiumExpirationDate { get; set; } public DateTime? RenewalReminderDate { get; set; } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 7cdd159224..47ddb86f8e 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.Billing.Premium.Models; +using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -24,6 +25,7 @@ public interface IUserRepository : IRepository /// Retrieves the data for the requested user IDs and includes an additional property indicating /// whether the user has premium access directly or through an organization. ///
+ [Obsolete("Use GetPremiumAccessByIdsAsync instead. This method will be removed in a future version.")] Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids); /// /// Retrieves the data for the requested user ID and includes additional property indicating @@ -34,8 +36,23 @@ public interface IUserRepository : IRepository /// /// The user ID to retrieve data for. /// User data with calculated premium access; null if nothing is found + [Obsolete("Use GetPremiumAccessAsync instead. This method will be removed in a future version.")] Task GetCalculatedPremiumAsync(Guid userId); /// + /// Retrieves premium access status for multiple users. + /// For internal use - consumers should use IHasPremiumAccessQuery instead. + /// + /// The user IDs to check + /// Collection of UserPremiumAccess objects containing premium status information + Task> GetPremiumAccessByIdsAsync(IEnumerable ids); + /// + /// Retrieves premium access status for a single user. + /// For internal use - consumers should use IHasPremiumAccessQuery instead. + /// + /// The user ID to check + /// UserPremiumAccess object containing premium status information, or null if user not found + Task GetPremiumAccessAsync(Guid userId); + /// /// Sets a new user key and updates all encrypted data. /// Warning: Any user key encrypted data not included will be lost. /// diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 0506e08cfc..fade63de51 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -60,7 +60,7 @@ public interface IUserService /// /// Checks if the user has access to premium features, either through a personal subscription or through an organization. /// - /// This is the preferred way to definitively know if a user has access to premium features. + /// This is the preferred way to definitively know if a user has access to premium features when you already have the User object. /// /// user being acted on /// true if they can access premium; false otherwise. @@ -74,6 +74,7 @@ public interface IUserService /// /// user being acted on /// true if they can access premium because of organization membership; false otherwise. + [Obsolete("Use IHasPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method will be removed in a future version.")] Task HasPremiumFromOrganization(User user); Task GenerateSignInTokenAsync(User user, string purpose); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index fbc382cb08..8db66211b1 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -17,6 +17,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; @@ -73,6 +74,7 @@ public class UserService : UserManager, IUserService private readonly IDistributedCache _distributedCache; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; + private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery; public UserService( IUserRepository userRepository, @@ -108,7 +110,8 @@ public class UserService : UserManager, IUserService ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDistributedCache distributedCache, IPolicyRequirementQuery policyRequirementQuery, - IPricingClient pricingClient) + IPricingClient pricingClient, + IHasPremiumAccessQuery hasPremiumAccessQuery) : base( store, optionsAccessor, @@ -149,6 +152,7 @@ public class UserService : UserManager, IUserService _distributedCache = distributedCache; _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; + _hasPremiumAccessQuery = hasPremiumAccessQuery; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -1112,6 +1116,11 @@ public class UserService : UserManager, IUserService return false; } + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + return user.Premium || await _hasPremiumAccessQuery.HasPremiumFromOrganizationAsync(userId.Value); + } + return user.Premium || await HasPremiumFromOrganization(user); } @@ -1123,6 +1132,11 @@ public class UserService : UserManager, IUserService return false; } + if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery)) + { + return await _hasPremiumAccessQuery.HasPremiumFromOrganizationAsync(userId.Value); + } + // orgUsers in the Invited status are not associated with a userId yet, so this will get // orgUsers in Accepted and Confirmed states only var orgUsers = await _organizationUserRepository.GetManyByUserAsync(userId.Value); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 86ab063a5f..224351f034 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -1,6 +1,7 @@ using System.Data; using System.Text.Json; using Bit.Core; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; @@ -381,6 +382,25 @@ public class UserRepository : Repository, IUserRepository return result.SingleOrDefault(); } + public async Task> GetPremiumAccessByIdsAsync(IEnumerable ids) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadPremiumAccessByIds]", + new { Ids = ids.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task GetPremiumAccessAsync(Guid userId) + { + var result = await GetPremiumAccessByIdsAsync([userId]); + return result.SingleOrDefault(); + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs index 47369f5e3d..93d8fe2d7d 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/OrganizationEntityTypeConfiguration.cs @@ -18,7 +18,7 @@ public class OrganizationEntityTypeConfiguration : IEntityTypeConfiguration new { o.Id, o.Enabled }), - o => o.UseTotp); + o => new { o.UseTotp, o.UsersGetPremium }); builder.ToTable(nameof(Organization)); } diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index a43c692be3..9bf093e506 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.Billing.Premium.Models; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -350,6 +351,36 @@ public class UserRepository : Repository, IUserR return result.FirstOrDefault(); } + public async Task> GetPremiumAccessByIdsAsync(IEnumerable ids) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var users = await dbContext.Users + .Where(x => ids.Contains(x.Id)) + .Include(u => u.OrganizationUsers) + .ThenInclude(ou => ou.Organization) + .ToListAsync(); + + return users.Select(user => new UserPremiumAccess + { + Id = user.Id, + PersonalPremium = user.Premium, + OrganizationPremium = user.OrganizationUsers + .Any(ou => ou.Organization != null && + ou.Organization.Enabled == true && + ou.Organization.UsersGetPremium == true) + }).ToList(); + } + } + + public async Task GetPremiumAccessAsync(Guid userId) + { + var result = await GetPremiumAccessByIdsAsync([userId]); + return result.FirstOrDefault(); + } + public override async Task DeleteAsync(Core.Entities.User user) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/User_ReadPremiumAccessByIds.sql b/src/Sql/dbo/Stored Procedures/User_ReadPremiumAccessByIds.sql new file mode 100644 index 0000000000..a4c73c39df --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_ReadPremiumAccessByIds.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[User_ReadPremiumAccessByIds] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + UPA.[Id], + UPA.[PersonalPremium], + UPA.[OrganizationPremium] + FROM + [dbo].[UserPremiumAccessView] UPA + WHERE + UPA.[Id] IN (SELECT [Id] FROM @Ids) +END diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql index d8635c8ac9..f07cd4ce0d 100644 --- a/src/Sql/dbo/Tables/Organization.sql +++ b/src/Sql/dbo/Tables/Organization.sql @@ -69,7 +69,7 @@ CREATE TABLE [dbo].[Organization] ( GO CREATE NONCLUSTERED INDEX [IX_Organization_Enabled] ON [dbo].[Organization]([Id] ASC, [Enabled] ASC) - INCLUDE ([UseTotp]); + INCLUDE ([UseTotp], [UsersGetPremium]); GO CREATE UNIQUE NONCLUSTERED INDEX [IX_Organization_Identifier] diff --git a/src/Sql/dbo/Views/UserPremiumAccessView.sql b/src/Sql/dbo/Views/UserPremiumAccessView.sql new file mode 100644 index 0000000000..a20cab8fb3 --- /dev/null +++ b/src/Sql/dbo/Views/UserPremiumAccessView.sql @@ -0,0 +1,21 @@ +CREATE VIEW [dbo].[UserPremiumAccessView] +AS +SELECT + U.[Id], + U.[Premium] AS [PersonalPremium], + CAST( + MAX(CASE + WHEN O.[Id] IS NOT NULL THEN 1 + ELSE 0 + END) AS BIT + ) AS [OrganizationPremium] +FROM + [dbo].[User] U +LEFT JOIN + [dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id] +LEFT JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + AND O.[UsersGetPremium] = 1 + AND O.[Enabled] = 1 +GROUP BY + U.[Id], U.[Premium]; diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs index adeac45d06..3a98fb44fb 100644 --- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs +++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs @@ -1,10 +1,13 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; +using Bit.Core.Billing.Premium.Queries; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -404,6 +407,277 @@ public class TwoFactorIsEnabledQueryTests .GetCalculatedPremiumAsync(default); } + [Theory] + [BitAutoData((IEnumerable)null)] + [BitAutoData([])] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_WithNoUserIds_ReturnsEmpty( + IEnumerable userIds, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + Assert.Empty(result); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_WithMixedScenarios_ReturnsCorrectResults( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User user1, + User user2, + User user3) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + var users = new List { user1, user2, user3 }; + var userIds = users.Select(u => u.Id).ToList(); + + // User 1: Non-premium provider → 2FA enabled + user1.SetTwoFactorProviders(new Dictionary + { + { TwoFactorProviderType.Authenticator, new TwoFactorProvider { Enabled = true } } + }); + + // User 2: Premium provider + has premium → 2FA enabled + user2.SetTwoFactorProviders(new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }); + + // User 3: Premium provider + no premium → 2FA disabled + user3.SetTwoFactorProviders(new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }); + + var premiumStatus = new Dictionary + { + { user2.Id, true }, + { user3.Id, false } + }; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.SequenceEqual(userIds))) + .Returns(users); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(Arg.Is>(ids => + ids.Count() == 2 && ids.Contains(user2.Id) && ids.Contains(user3.Id))) + .Returns(premiumStatus); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert + Assert.Contains(result, res => res.userId == user1.Id && res.twoFactorIsEnabled == true); // Non-premium provider + Assert.Contains(result, res => res.userId == user2.Id && res.twoFactorIsEnabled == true); // Premium + has premium + Assert.Contains(result, res => res.userId == user3.Id && res.twoFactorIsEnabled == false); // Premium + no premium + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Duo)] + [BitAutoData(TwoFactorProviderType.YubiKey)] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_OnlyChecksPremiumAccessForUsersWhoNeedIt( + TwoFactorProviderType premiumProviderType, + SutProvider sutProvider, + User user1, + User user2, + User user3) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + var users = new List { user1, user2, user3 }; + var userIds = users.Select(u => u.Id).ToList(); + + // User 1: Has non-premium provider - should NOT trigger premium check + user1.SetTwoFactorProviders(new Dictionary + { + { TwoFactorProviderType.Authenticator, new TwoFactorProvider { Enabled = true } } + }); + + // User 2 & 3: Have only premium providers - SHOULD trigger premium check + user2.SetTwoFactorProviders(new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }); + user3.SetTwoFactorProviders(new Dictionary + { + { premiumProviderType, new TwoFactorProvider { Enabled = true } } + }); + + var premiumStatus = new Dictionary + { + { user2.Id, true }, + { user3.Id, false } + }; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.SequenceEqual(userIds))) + .Returns(users); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(Arg.Is>(ids => + ids.Count() == 2 && ids.Contains(user2.Id) && ids.Contains(user3.Id))) + .Returns(premiumStatus); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(userIds); + + // Assert - Verify optimization: premium checked ONLY for users 2 and 3 (not user 1) + await sutProvider.GetDependency() + .Received(1) + .HasPremiumAccessAsync(Arg.Is>(ids => + ids.Count() == 2 && ids.Contains(user2.Id) && ids.Contains(user3.Id))); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_WithNoUserIds_ReturnsAllTwoFactorDisabled( + SutProvider sutProvider, + List users) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + foreach (var user in users) + { + user.UserId = null; + } + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(users); + + // Assert + foreach (var user in users) + { + Assert.Contains(result, res => res.user.Equals(user) && res.twoFactorIsEnabled == false); + } + + // No UserIds were supplied so no calls to the UserRepository should have been made + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetManyAsync(default); + } + + [Theory] + [BitAutoData(TwoFactorProviderType.Authenticator, true)] // Non-premium provider + [BitAutoData(TwoFactorProviderType.Duo, true)] // Premium provider with premium access + [BitAutoData(TwoFactorProviderType.YubiKey, false)] // Premium provider without premium access + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_SingleUser_VariousScenarios( + TwoFactorProviderType providerType, + bool hasPremiumAccess, + SutProvider sutProvider, + User user) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + user.SetTwoFactorProviders(new Dictionary + { + { providerType, new TwoFactorProvider { Enabled = true } } + }); + + sutProvider.GetDependency() + .HasPremiumAccessAsync(user.Id) + .Returns(hasPremiumAccess); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + var requiresPremium = TwoFactorProvider.RequiresPremium(providerType); + var expectedResult = !requiresPremium || hasPremiumAccess; + Assert.Equal(expectedResult, result); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_WithNoEnabledProviders_ReturnsFalse( + SutProvider sutProvider, + User user) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + user.SetTwoFactorProviders(new Dictionary + { + { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } } + }); + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_WithNullProviders_ReturnsFalse( + SutProvider sutProvider, + User user) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + user.TwoFactorProviders = null; + + // Act + var result = await sutProvider.Sut.TwoFactorIsEnabledAsync(user); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public async Task TwoFactorIsEnabledAsync_WhenPremiumAccessQueryEnabled_UserNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + Guid userId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PremiumAccessQuery) + .Returns(true); + + var testUser = new TestTwoFactorProviderUser + { + Id = userId, + TwoFactorProviders = null + }; + + sutProvider.GetDependency() + .GetByIdAsync(userId) + .Returns((User)null); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.TwoFactorIsEnabledAsync(testUser)); + } + private class TestTwoFactorProviderUser : ITwoFactorProvidersUser { public Guid? Id { get; set; } @@ -418,10 +692,5 @@ public class TwoFactorIsEnabledQueryTests { return Id; } - - public bool GetPremium() - { - return Premium; - } } } diff --git a/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs new file mode 100644 index 0000000000..31547dffbe --- /dev/null +++ b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs @@ -0,0 +1,234 @@ +using Bit.Core.Billing.Premium.Models; +using Bit.Core.Billing.Premium.Queries; +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.Billing.Premium.Queries; + +[SutProviderCustomize] +public class HasPremiumAccessQueryTests +{ + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserHasPersonalPremium_ReturnsTrue( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = true; + user.OrganizationPremium = false; + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = false; + user.OrganizationPremium = true; // Has org premium + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = false; + user.OrganizationPremium = false; + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_WhenUserNotFound_ThrowsNotFoundException( + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetPremiumAccessAsync(userId) + .Returns((UserPremiumAccess?)null); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.HasPremiumAccessAsync(userId)); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = false; + user.OrganizationPremium = false; // No premium from anywhere + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasPremiumFromOrg_ReturnsTrue( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = false; // No personal premium + user.OrganizationPremium = true; // But has premium from org + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasOnlyPersonalPremium_ReturnsFalse( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = true; // Has personal premium + user.OrganizationPremium = false; // Not in any org that grants premium + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.False(result); // Should return false because user is not in an org that grants premium + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserHasBothPersonalAndOrgPremium_ReturnsTrue( + UserPremiumAccess user, + SutProvider sutProvider) + { + // Arrange + user.PersonalPremium = true; // Has personal premium + user.OrganizationPremium = true; // Also in an org that grants premium + + sutProvider.GetDependency() + .GetPremiumAccessAsync(user.Id) + .Returns(user); + + // Act + var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id); + + // Assert + Assert.True(result); // Should return true because user IS in an org that grants premium (regardless of personal premium) + } + + [Theory, BitAutoData] + public async Task HasPremiumFromOrganizationAsync_WhenUserNotFound_ThrowsNotFoundException( + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetPremiumAccessAsync(userId) + .Returns((UserPremiumAccess?)null); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.HasPremiumFromOrganizationAsync(userId)); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionary( + SutProvider sutProvider) + { + // Arrange + var userIds = new List(); + + sutProvider.GetDependency() + .GetPremiumAccessByIdsAsync(userIds) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds); + + // Assert + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task HasPremiumAccessAsync_Bulk_ReturnsCorrectStatus( + UserPremiumAccess user1, + UserPremiumAccess user2, + UserPremiumAccess user3, + SutProvider sutProvider) + { + // Arrange + user1.PersonalPremium = true; + user1.OrganizationPremium = false; + user2.PersonalPremium = false; + user2.OrganizationPremium = false; + user3.PersonalPremium = false; + user3.OrganizationPremium = true; + + var users = new List { user1, user2, user3 }; + var userIds = users.Select(u => u.Id).ToList(); + + sutProvider.GetDependency() + .GetPremiumAccessByIdsAsync(Arg.Is>(ids => ids.SequenceEqual(userIds))) + .Returns(users); + + // Act + var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds); + + // Assert + Assert.Equal(3, result.Count); + Assert.True(result[user1.Id]); // Personal premium + Assert.False(result[user2.Id]); // No premium + Assert.True(result[user3.Id]); // Organization premium + } +} diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs index dd84df07be..bbbd6d5cdb 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs @@ -179,4 +179,325 @@ public class UserRepositoryTests Assert.Equal(CollectionType.SharedCollection, updatedCollection2.Type); Assert.Equal(user2.Email, updatedCollection2.DefaultUserCollectionEmail); } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_WithPersonalPremium_ReturnsCorrectAccess( + IUserRepository userRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Premium User", + Email = $"premium+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = true + }); + + // Act + var result = await userRepository.GetPremiumAccessAsync(user.Id); + + // Assert + Assert.NotNull(result); + Assert.True(result.PersonalPremium); + Assert.False(result.OrganizationPremium); + Assert.True(result.HasPremiumAccess); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_WithOrganizationPremium_ReturnsCorrectAccess( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Org User", + Email = $"org+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + + // Act + var result = await userRepository.GetPremiumAccessAsync(user.Id); + + // Assert + Assert.NotNull(result); + Assert.False(result.PersonalPremium); + Assert.True(result.OrganizationPremium); + Assert.True(result.HasPremiumAccess); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_WithDisabledOrganization_ReturnsNoOrganizationPremium( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "User", + Email = $"user+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + organization.Enabled = false; + await organizationRepository.ReplaceAsync(organization); + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + + // Act + var result = await userRepository.GetPremiumAccessAsync(user.Id); + + // Assert + Assert.NotNull(result); + Assert.False(result.OrganizationPremium); + Assert.False(result.HasPremiumAccess); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_WithOrganizationUsersGetPremiumFalse_ReturnsNoOrganizationPremium( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + organization.UsersGetPremium = false; + await organizationRepository.ReplaceAsync(organization); + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + + // Act + var result = await userRepository.GetPremiumAccessAsync(user.Id); + + // Assert + Assert.NotNull(result); + Assert.False(result.OrganizationPremium); + Assert.False(result.HasPremiumAccess); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_WithMultipleOrganizations_OneProvidesPremium_ReturnsOrganizationPremium( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "User With Premium Org", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var orgWithPremium = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateTestOrganizationUserAsync(orgWithPremium, user); + + var orgNoPremium = await organizationRepository.CreateTestOrganizationAsync(); + orgNoPremium.UsersGetPremium = false; + await organizationRepository.ReplaceAsync(orgNoPremium); + await organizationUserRepository.CreateTestOrganizationUserAsync(orgNoPremium, user); + + // Act + var result = await userRepository.GetPremiumAccessAsync(user.Id); + + // Assert + Assert.NotNull(result); + Assert.False(result.PersonalPremium); + Assert.True(result.OrganizationPremium); + Assert.True(result.HasPremiumAccess); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_WithMultipleOrganizations_NoneProvidePremium_ReturnsNoOrganizationPremium( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "User With No Premium Orgs", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var disabledOrg = await organizationRepository.CreateTestOrganizationAsync(); + disabledOrg.Enabled = false; + await organizationRepository.ReplaceAsync(disabledOrg); + await organizationUserRepository.CreateTestOrganizationUserAsync(disabledOrg, user); + + var orgNoPremium = await organizationRepository.CreateTestOrganizationAsync(); + orgNoPremium.UsersGetPremium = false; + await organizationRepository.ReplaceAsync(orgNoPremium); + await organizationUserRepository.CreateTestOrganizationUserAsync(orgNoPremium, user); + + // Act + var result = await userRepository.GetPremiumAccessAsync(user.Id); + + // Assert + Assert.NotNull(result); + Assert.False(result.PersonalPremium); + Assert.False(result.OrganizationPremium); + Assert.False(result.HasPremiumAccess); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessAsync_NonExistentUser_ReturnsNull( + IUserRepository userRepository) + { + // Act + var result = await userRepository.GetPremiumAccessAsync(Guid.NewGuid()); + + // Assert + Assert.Null(result); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessByIdsAsync_MultipleUsers_ReturnsCorrectAccessForEach( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository) + { + // Arrange + var personalPremiumUser = await userRepository.CreateAsync(new User + { + Name = "Personal Premium", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = true + }); + + var orgPremiumUser = await userRepository.CreateAsync(new User + { + Name = "Org Premium", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var bothPremiumUser = await userRepository.CreateAsync(new User + { + Name = "Both Premium", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = true + }); + + var noPremiumUser = await userRepository.CreateAsync(new User + { + Name = "No Premium", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var multiOrgUser = await userRepository.CreateAsync(new User + { + Name = "Multi Org User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = false + }); + + var personalPremiumWithDisabledOrg = await userRepository.CreateAsync(new User + { + Name = "Personal Premium With Disabled Org", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + Premium = true + }); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, orgPremiumUser); + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, bothPremiumUser); + await organizationUserRepository.CreateTestOrganizationUserAsync(organization, multiOrgUser); + + var orgWithoutPremium = await organizationRepository.CreateTestOrganizationAsync(); + orgWithoutPremium.UsersGetPremium = false; + await organizationRepository.ReplaceAsync(orgWithoutPremium); + await organizationUserRepository.CreateTestOrganizationUserAsync(orgWithoutPremium, multiOrgUser); + + var disabledOrg = await organizationRepository.CreateTestOrganizationAsync(); + disabledOrg.Enabled = false; + await organizationRepository.ReplaceAsync(disabledOrg); + await organizationUserRepository.CreateTestOrganizationUserAsync(disabledOrg, personalPremiumWithDisabledOrg); + + // Act + var results = await userRepository.GetPremiumAccessByIdsAsync([ + personalPremiumUser.Id, + orgPremiumUser.Id, + bothPremiumUser.Id, + noPremiumUser.Id, + multiOrgUser.Id, + personalPremiumWithDisabledOrg.Id + ]); + + var resultsList = results.ToList(); + + // Assert + Assert.Equal(6, resultsList.Count); + + var personalResult = resultsList.First(r => r.Id == personalPremiumUser.Id); + Assert.True(personalResult.PersonalPremium); + Assert.False(personalResult.OrganizationPremium); + + var orgResult = resultsList.First(r => r.Id == orgPremiumUser.Id); + Assert.False(orgResult.PersonalPremium); + Assert.True(orgResult.OrganizationPremium); + + var bothResult = resultsList.First(r => r.Id == bothPremiumUser.Id); + Assert.True(bothResult.PersonalPremium); + Assert.True(bothResult.OrganizationPremium); + + var noneResult = resultsList.First(r => r.Id == noPremiumUser.Id); + Assert.False(noneResult.PersonalPremium); + Assert.False(noneResult.OrganizationPremium); + + var multiResult = resultsList.First(r => r.Id == multiOrgUser.Id); + Assert.False(multiResult.PersonalPremium); + Assert.True(multiResult.OrganizationPremium); + + var personalWithDisabledOrgResult = resultsList.First(r => r.Id == personalPremiumWithDisabledOrg.Id); + Assert.True(personalWithDisabledOrgResult.PersonalPremium); + Assert.False(personalWithDisabledOrgResult.OrganizationPremium); + } + + [Theory, DatabaseData] + public async Task GetPremiumAccessByIdsAsync_EmptyList_ReturnsEmptyResult( + IUserRepository userRepository) + { + // Act + var results = await userRepository.GetPremiumAccessByIdsAsync([]); + + // Assert + Assert.Empty(results); + } } diff --git a/util/Migrator/DbScripts/2025-12-12_00_UserPremiumAccessView.sql b/util/Migrator/DbScripts/2025-12-12_00_UserPremiumAccessView.sql new file mode 100644 index 0000000000..b467f29acc --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-12_00_UserPremiumAccessView.sql @@ -0,0 +1,60 @@ +-- Add UsersGetPremium to IX_Organization_Enabled index to support premium access queries + +IF EXISTS ( + SELECT * FROM sys.indexes + WHERE name = 'IX_Organization_Enabled' + AND object_id = OBJECT_ID('[dbo].[Organization]') +) +BEGIN + CREATE NONCLUSTERED INDEX [IX_Organization_Enabled] + ON [dbo].[Organization]([Id] ASC, [Enabled] ASC) + INCLUDE ([UseTotp], [UsersGetPremium]) + WITH (DROP_EXISTING = ON); +END +ELSE +BEGIN + CREATE NONCLUSTERED INDEX [IX_Organization_Enabled] + ON [dbo].[Organization]([Id] ASC, [Enabled] ASC) + INCLUDE ([UseTotp], [UsersGetPremium]); +END +GO + +CREATE OR ALTER VIEW [dbo].[UserPremiumAccessView] +AS +SELECT + U.[Id], + U.[Premium] AS [PersonalPremium], + CAST( + MAX(CASE + WHEN O.[Id] IS NOT NULL THEN 1 + ELSE 0 + END) AS BIT + ) AS [OrganizationPremium] +FROM + [dbo].[User] U +LEFT JOIN + [dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id] +LEFT JOIN + [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] + AND O.[UsersGetPremium] = 1 + AND O.[Enabled] = 1 +GROUP BY + U.[Id], U.[Premium]; +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_ReadPremiumAccessByIds] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + UPA.[Id], + UPA.[PersonalPremium], + UPA.[OrganizationPremium] + FROM + [dbo].[UserPremiumAccessView] UPA + WHERE + UPA.[Id] IN (SELECT [Id] FROM @Ids) +END +GO diff --git a/util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.Designer.cs b/util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.Designer.cs new file mode 100644 index 0000000000..72bdc1fb41 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.Designer.cs @@ -0,0 +1,3443 @@ +// +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("20251212171212_OrganizationUsersGetPremiumIndex")] + partial class OrganizationUsersGetPremiumIndex + { + /// + 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", "UsersGetPremium" }); + + 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/20251212171212_OrganizationUsersGetPremiumIndex.cs b/util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.cs new file mode 100644 index 0000000000..4701195022 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20251212171212_OrganizationUsersGetPremiumIndex.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class OrganizationUsersGetPremiumIndex : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 06e05d2be8..2fef7dd841 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -274,7 +274,7 @@ namespace Bit.MySqlMigrations.Migrations b.HasKey("Id"); b.HasIndex("Id", "Enabled") - .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); b.ToTable("Organization", (string)null); }); diff --git a/util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.Designer.cs b/util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.Designer.cs new file mode 100644 index 0000000000..b19db8c343 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.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("20251212171204_OrganizationUsersGetPremiumIndex")] + partial class OrganizationUsersGetPremiumIndex + { + /// + 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", "UsersGetPremium" }); + + 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/20251212171204_OrganizationUsersGetPremiumIndex.cs b/util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.cs new file mode 100644 index 0000000000..6754e49c43 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20251212171204_OrganizationUsersGetPremiumIndex.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class OrganizationUsersGetPremiumIndex : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Organization_Id_Enabled", + table: "Organization"); + + migrationBuilder.CreateIndex( + name: "IX_Organization_Id_Enabled", + table: "Organization", + columns: new[] { "Id", "Enabled" }) + .Annotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Organization_Id_Enabled", + table: "Organization"); + + migrationBuilder.CreateIndex( + name: "IX_Organization_Id_Enabled", + table: "Organization", + columns: new[] { "Id", "Enabled" }) + .Annotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index b66f08fdc9..09054fbf2a 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -277,7 +277,7 @@ namespace Bit.PostgresMigrations.Migrations b.HasIndex("Id", "Enabled"); - NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" }); b.ToTable("Organization", (string)null); }); diff --git a/util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.Designer.cs b/util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.Designer.cs new file mode 100644 index 0000000000..0958b2bfb6 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.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("20251212171156_OrganizationUsersGetPremiumIndex")] + partial class OrganizationUsersGetPremiumIndex + { + /// + 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", "UsersGetPremium" }); + + 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/20251212171156_OrganizationUsersGetPremiumIndex.cs b/util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.cs new file mode 100644 index 0000000000..122cfde530 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20251212171156_OrganizationUsersGetPremiumIndex.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class OrganizationUsersGetPremiumIndex : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 63e0fd5748..d9fa1ff599 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -269,7 +269,7 @@ namespace Bit.SqliteMigrations.Migrations b.HasKey("Id"); b.HasIndex("Id", "Enabled") - .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); b.ToTable("Organization", (string)null); }); From 2ecd6c8d5f7a3ebbd53edeb5649d905d110027f4 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:15:53 +0100 Subject: [PATCH 19/47] Fix the duplicate issue (#6711) --- .../StripeEventUtilityService.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 06a5d8a890..ba3e79abc6 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -289,20 +289,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService } var btInvoiceAmount = Math.Round(invoice.AmountDue / 100M, 2); - var existingTransactions = organizationId.HasValue - ? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value) - : userId.HasValue - ? await _transactionRepository.GetManyByUserIdAsync(userId.Value) - : await _transactionRepository.GetManyByProviderIdAsync(providerId.Value); - - var duplicateTimeSpan = TimeSpan.FromHours(24); - var now = DateTime.UtcNow; - var duplicateTransaction = existingTransactions? - .FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan); - if (duplicateTransaction != null) + // Check if this invoice already has a Braintree transaction ID to prevent duplicate charges + if (invoice.Metadata?.ContainsKey("btTransactionId") ?? false) { - _logger.LogWarning("There is already a recent PayPal transaction ({0}). " + - "Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId); + _logger.LogWarning("Invoice {InvoiceId} already has a Braintree transaction ({TransactionId}). " + + "Do not charge again to prevent duplicate.", + invoice.Id, + invoice.Metadata["btTransactionId"]); return false; } From 39a6719361e290643e67fbb9fb168af85c8ff34c Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 16 Dec 2025 07:59:05 -0600 Subject: [PATCH 20/47] [PM-27117] Sync Stripe Customer details for Organizations and Providers in API & Admin (#6679) * Sync Stripe customer details for Provider / Organization in API & Admin * Remove unnecessary var * Fix logical operator * Remove customer ID check from callers * Fix failing tests * Missed conflicts --- .../Services/ProviderBillingService.cs | 38 +++++ .../Services/ProviderBillingServiceTests.cs | 147 ++++++++++++++++++ .../Controllers/OrganizationsController.cs | 24 ++- .../Controllers/ProvidersController.cs | 24 ++- .../Controllers/ProvidersController.cs | 28 +++- .../Update/OrganizationUpdateCommand.cs | 2 +- .../Services/IOrganizationBillingService.cs | 4 - .../Services/OrganizationBillingService.cs | 20 ++- .../Services/IProviderBillingService.cs | 7 + .../OrganizationUpdateCommandTests.cs | 12 +- .../OrganizationBillingServiceTests.cs | 105 +++++++++++-- 11 files changed, 377 insertions(+), 34 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 41734663c2..7042a531d0 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -796,6 +796,44 @@ public class ProviderBillingService( } } + public async Task UpdateProviderNameAndEmail(Provider provider) + { + if (string.IsNullOrWhiteSpace(provider.GatewayCustomerId)) + { + logger.LogWarning( + "Provider ({ProviderId}) has no Stripe customer to update", + provider.Id); + return; + } + + var newDisplayName = provider.DisplayName(); + + // Provider.DisplayName() can return null - handle gracefully + if (string.IsNullOrWhiteSpace(newDisplayName)) + { + logger.LogWarning( + "Provider ({ProviderId}) has no name to update in Stripe", + provider.Id); + return; + } + + await stripeAdapter.UpdateCustomerAsync(provider.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = provider.BillingEmail, + Description = newDisplayName, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = newDisplayName + }] + }, + }); + } + private Func CurrySeatScalingUpdate( Provider provider, ProviderPlan providerPlan, diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 76c5b30dd8..93ce33edc4 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -2150,4 +2150,151 @@ public class ProviderBillingServiceTests } #endregion + + #region UpdateProviderNameAndEmail + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.GatewayCustomerId = null; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.GatewayCustomerId = ""; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = null; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = ""; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = "Test Provider"; + provider.BillingEmail = "billing@test.com"; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.Email == provider.BillingEmail && + options.Description == provider.Name && + options.InvoiceSettings.CustomFields.Count == 1 && + options.InvoiceSettings.CustomFields[0].Name == "Provider" && + options.InvoiceSettings.CustomFields[0].Value == provider.Name)); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_LongProviderName_UsesFullName( + Provider provider, + SutProvider sutProvider) + { + // Arrange + var longName = new string('A', 50); // 50 characters + provider.Name = longName; + provider.BillingEmail = "billing@test.com"; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.InvoiceSettings.CustomFields[0].Value == longName)); + } + + [Theory, BitAutoData] + public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull( + Provider provider, + SutProvider sutProvider) + { + // Arrange + provider.Name = "Test Provider"; + provider.BillingEmail = null; + provider.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateProviderNameAndEmail(provider); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + provider.GatewayCustomerId, + Arg.Is(options => + options.Email == null && + options.Description == provider.Name)); + } + + #endregion } diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index a99f70bf65..cd370e3898 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; @@ -57,6 +58,7 @@ public class OrganizationsController : Controller private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IOrganizationBillingService _organizationBillingService; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -81,7 +83,8 @@ public class OrganizationsController : Controller IProviderBillingService providerBillingService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, IPricingClient pricingClient, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IOrganizationBillingService organizationBillingService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -106,6 +109,7 @@ public class OrganizationsController : Controller _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _organizationBillingService = organizationBillingService; } [RequirePermission(Permission.Org_List_View)] @@ -242,6 +246,8 @@ public class OrganizationsController : Controller var existingOrganizationData = new Organization { Id = organization.Id, + Name = organization.Name, + BillingEmail = organization.BillingEmail, Status = organization.Status, PlanType = organization.PlanType, Seats = organization.Seats @@ -287,6 +293,22 @@ public class OrganizationsController : Controller await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + // Sync name/email changes to Stripe + if (existingOrganizationData.Name != organization.Name || existingOrganizationData.BillingEmail != organization.BillingEmail) + { + try + { + await _organizationBillingService.UpdateOrganizationNameAndEmail(organization); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update Stripe customer for organization {OrganizationId}. Database was updated successfully.", + organization.Id); + TempData["Warning"] = "Organization updated successfully, but Stripe customer name/email synchronization failed."; + } + } + return RedirectToAction("Edit", new { id }); } diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index b6a959a386..d9135e1d1c 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -56,6 +56,7 @@ public class ProvidersController : Controller private readonly IStripeAdapter _stripeAdapter; private readonly IAccessControlService _accessControlService; private readonly ISubscriberService _subscriberService; + private readonly ILogger _logger; public ProvidersController(IOrganizationRepository organizationRepository, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, @@ -72,7 +73,8 @@ public class ProvidersController : Controller IPricingClient pricingClient, IStripeAdapter stripeAdapter, IAccessControlService accessControlService, - ISubscriberService subscriberService) + ISubscriberService subscriberService, + ILogger logger) { _organizationRepository = organizationRepository; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; @@ -92,6 +94,7 @@ public class ProvidersController : Controller _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; _subscriberService = subscriberService; + _logger = logger; } [RequirePermission(Permission.Provider_List_View)] @@ -296,6 +299,9 @@ public class ProvidersController : Controller var originalProviderStatus = provider.Enabled; + // Capture original billing email before modifications for Stripe sync + var originalBillingEmail = provider.BillingEmail; + model.ToProvider(provider); // validate the stripe ids to prevent saving a bad one @@ -321,6 +327,22 @@ public class ProvidersController : Controller await _providerService.UpdateAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); + // Sync billing email changes to Stripe + if (!string.IsNullOrEmpty(provider.GatewayCustomerId) && originalBillingEmail != provider.BillingEmail) + { + try + { + await _providerBillingService.UpdateProviderNameAndEmail(provider); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.", + provider.Id); + TempData["Warning"] = "Provider updated successfully, but Stripe customer email synchronization failed."; + } + } + if (!provider.IsBillable()) { return RedirectToAction("Edit", new { id }); diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index aa87bf9c74..515404e8a9 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Services; @@ -23,15 +24,20 @@ public class ProvidersController : Controller private readonly IProviderService _providerService; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IProviderBillingService _providerBillingService; + private readonly ILogger _logger; public ProvidersController(IUserService userService, IProviderRepository providerRepository, - IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings) + IProviderService providerService, ICurrentContext currentContext, GlobalSettings globalSettings, + IProviderBillingService providerBillingService, ILogger logger) { _userService = userService; _providerRepository = providerRepository; _providerService = providerService; _currentContext = currentContext; _globalSettings = globalSettings; + _providerBillingService = providerBillingService; + _logger = logger; } [HttpGet("{id:guid}")] @@ -65,7 +71,27 @@ public class ProvidersController : Controller throw new NotFoundException(); } + // Capture original values before modifications for Stripe sync + var originalName = provider.Name; + var originalBillingEmail = provider.BillingEmail; + await _providerService.UpdateAsync(model.ToProvider(provider, _globalSettings)); + + // Sync name/email changes to Stripe + if (originalName != provider.Name || originalBillingEmail != provider.BillingEmail) + { + try + { + await _providerBillingService.UpdateProviderNameAndEmail(provider); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.", + provider.Id); + } + } + return new ProviderResponseModel(provider); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs index 64358f3048..83318fd1e6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -67,7 +67,7 @@ public class OrganizationUpdateCommand( var shouldUpdateBilling = originalName != organization.Name || originalBillingEmail != organization.BillingEmail; - if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + if (!shouldUpdateBilling) { return; } diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index 6c7f087ffa..39d2a789e6 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -61,10 +61,6 @@ public interface IOrganizationBillingService /// Updates the organization name and email on the Stripe customer entry. /// This only updates Stripe, not the Bitwarden database. /// - /// - /// The caller should ensure that the organization has a GatewayCustomerId before calling this method. - /// /// The organization to update in Stripe. - /// Thrown when the organization does not have a GatewayCustomerId. Task UpdateOrganizationNameAndEmail(Organization organization); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 162fb488f6..a1b57c2415 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -177,13 +177,25 @@ public class OrganizationBillingService( public async Task UpdateOrganizationNameAndEmail(Organization organization) { - if (organization.GatewayCustomerId is null) + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { - throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId."); + logger.LogWarning( + "Organization ({OrganizationId}) has no Stripe customer to update", + organization.Id); + return; } var newDisplayName = organization.DisplayName(); + // Organization.DisplayName() can return null - handle gracefully + if (string.IsNullOrWhiteSpace(newDisplayName)) + { + logger.LogWarning( + "Organization ({OrganizationId}) has no name to update in Stripe", + organization.Id); + return; + } + await stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { @@ -196,9 +208,7 @@ public class OrganizationBillingService( new CustomerInvoiceSettingsCustomFieldOptions { Name = organization.SubscriberType(), - Value = newDisplayName.Length <= 30 - ? newDisplayName - : newDisplayName[..30] + Value = newDisplayName }] }, }); diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index 57d68db038..3f5a48e817 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -113,4 +113,11 @@ public interface IProviderBillingService TaxInformation taxInformation); Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command); + + /// + /// Updates the provider name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// The provider to update in Stripe. + Task UpdateProviderNameAndEmail(Provider provider); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs index 3a60a6ffd2..d547d80aed 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateCommandTests.cs @@ -30,7 +30,7 @@ public class OrganizationUpdateCommandTests var organizationBillingService = sutProvider.GetDependency(); organization.Id = organizationId; - organization.GatewayCustomerId = null; // No Stripe customer, so no billing update + organization.GatewayCustomerId = null; // No Stripe customer, but billing update is still called organizationRepository .GetByIdAsync(organizationId) @@ -61,8 +61,8 @@ public class OrganizationUpdateCommandTests result, EventType.Organization_Updated); await organizationBillingService - .DidNotReceiveWithAnyArgs() - .UpdateOrganizationNameAndEmail(Arg.Any()); + .Received(1) + .UpdateOrganizationNameAndEmail(result); } [Theory, BitAutoData] @@ -93,7 +93,7 @@ public class OrganizationUpdateCommandTests [Theory] [BitAutoData("")] [BitAutoData((string)null)] - public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate( + public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpdateButHandledGracefully( string gatewayCustomerId, Guid organizationId, Organization organization, @@ -133,8 +133,8 @@ public class OrganizationUpdateCommandTests result, EventType.Organization_Updated); await organizationBillingService - .DidNotReceiveWithAnyArgs() - .UpdateOrganizationNameAndEmail(Arg.Any()); + .Received(1) + .UpdateOrganizationNameAndEmail(result); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 0ca1ecfe73..f1b9446b6d 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Sales; @@ -391,12 +390,13 @@ public class OrganizationBillingServiceTests } [Theory, BitAutoData] - public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters( + public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_UsesFullName( Organization organization, SutProvider sutProvider) { // Arrange - organization.Name = "This is a very long organization name that exceeds thirty characters"; + var longName = "This is a very long organization name that exceeds thirty characters"; + organization.Name = longName; CustomerUpdateOptions capturedOptions = null; sutProvider.GetDependency() @@ -420,14 +420,11 @@ public class OrganizationBillingServiceTests Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields); var customField = capturedOptions.InvoiceSettings.CustomFields.First(); - Assert.Equal(30, customField.Value.Length); - - var expectedCustomFieldDisplayName = "This is a very long organizati"; - Assert.Equal(expectedCustomFieldDisplayName, customField.Value); + Assert.Equal(longName, customField.Value); } [Theory, BitAutoData] - public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_ThrowsBillingException( + public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_LogsWarningAndReturns( Organization organization, SutProvider sutProvider) { @@ -435,15 +432,93 @@ public class OrganizationBillingServiceTests organization.GatewayCustomerId = null; organization.Name = "Test Organization"; organization.BillingEmail = "billing@example.com"; + var stripeAdapter = sutProvider.GetDependency(); - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization)); + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); - Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response); + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpdateCustomerAsync(Arg.Any(), Arg.Any()); + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsEmpty_LogsWarningAndReturns( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.GatewayCustomerId = ""; + organization.Name = "Test Organization"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenNameIsNull_LogsWarningAndReturns( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = null; + organization.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenNameIsEmpty_LogsWarningAndReturns( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = ""; + organization.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), + Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationNameAndEmail_WhenBillingEmailIsNull_UpdatesWithNull( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Name = "Test Organization"; + organization.BillingEmail = null; + organization.GatewayCustomerId = "cus_test123"; + var stripeAdapter = sutProvider.GetDependency(); + + // Act + await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization); + + // Assert + await stripeAdapter.Received(1).UpdateCustomerAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Email == null && + options.Description == organization.Name)); } } From 794240f108783ddc169615820ba426eccdc2d6ca Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:58:57 -0600 Subject: [PATCH 21/47] [PM-29732] (fix) storage job no longer ignores trialing and past_due statuses (#6737) --- .../Jobs/ReconcileAdditionalStorageJob.cs | 40 ++--- .../ReconcileAdditionalStorageJobTests.cs | 152 +++++++++++++++++- 2 files changed, 163 insertions(+), 29 deletions(-) diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index d891fc18ff..312ed3122b 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -39,15 +39,11 @@ public class ReconcileAdditionalStorageJob( logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode); var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId }; + var stripeStatusesToProcess = new[] { StripeConstants.SubscriptionStatus.Active, StripeConstants.SubscriptionStatus.Trialing, StripeConstants.SubscriptionStatus.PastDue }; foreach (var priceId in priceIds) { - var options = new SubscriptionListOptions - { - Limit = 100, - Status = StripeConstants.SubscriptionStatus.Active, - Price = priceId - }; + var options = new SubscriptionListOptions { Limit = 100, Price = priceId }; await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options)) { @@ -64,7 +60,7 @@ public class ReconcileAdditionalStorageJob( failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" : string.Empty - ); + ); return; } @@ -73,6 +69,12 @@ public class ReconcileAdditionalStorageJob( continue; } + if (!stripeStatusesToProcess.Contains(subscription.Status)) + { + logger.LogInformation("Skipping subscription with unsupported status: {SubscriptionId} - {Status}", subscription.Id, subscription.Status); + continue; + } + logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id); subscriptionsFound++; @@ -133,7 +135,7 @@ public class ReconcileAdditionalStorageJob( failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" : string.Empty - ); + ); } private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions( @@ -145,15 +147,7 @@ public class ReconcileAdditionalStorageJob( return null; } - var updateOptions = new SubscriptionUpdateOptions - { - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - Metadata = new Dictionary - { - [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") - }, - Items = [] - }; + var updateOptions = new SubscriptionUpdateOptions { ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Metadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") }, Items = [] }; var hasUpdates = false; @@ -172,11 +166,7 @@ public class ReconcileAdditionalStorageJob( newQuantity, item.Price.Id); - updateOptions.Items.Add(new SubscriptionItemOptions - { - Id = item.Id, - Quantity = newQuantity - }); + updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Quantity = newQuantity }); } else { @@ -185,11 +175,7 @@ public class ReconcileAdditionalStorageJob( currentQuantity, item.Price.Id); - updateOptions.Items.Add(new SubscriptionItemOptions - { - Id = item.Id, - Deleted = true - }); + updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Deleted = true }); } } diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs index deb164f232..b3540246b0 100644 --- a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -62,7 +62,7 @@ public class ReconcileAdditionalStorageJobTests // Assert _stripeFacade.Received(3).ListSubscriptionsAutoPagingAsync( - Arg.Is(o => o.Status == "active")); + Arg.Is(o => o.Limit == 100)); } #endregion @@ -553,6 +553,152 @@ public class ReconcileAdditionalStorageJobTests #endregion + #region Subscription Status Filtering Tests + + [Fact] + public async Task Execute_ActiveStatusSubscription_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + [Fact] + public async Task Execute_TrialingStatusSubscription_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Trialing); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + [Fact] + public async Task Execute_PastDueStatusSubscription_ProcessesSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.PastDue); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any()); + } + + [Fact] + public async Task Execute_CanceledStatusSubscription_SkipsSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Canceled); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_IncompleteStatusSubscription_SkipsSubscription() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Incomplete); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); + } + + [Fact] + public async Task Execute_MixedSubscriptionStatuses_OnlyProcessesValidStatuses() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + var activeSubscription = CreateSubscription("sub_active", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active); + var trialingSubscription = CreateSubscription("sub_trialing", "storage-gb-monthly", quantity: 8, status: StripeConstants.SubscriptionStatus.Trialing); + var pastDueSubscription = CreateSubscription("sub_pastdue", "storage-gb-monthly", quantity: 6, status: StripeConstants.SubscriptionStatus.PastDue); + var canceledSubscription = CreateSubscription("sub_canceled", "storage-gb-monthly", quantity: 5, status: StripeConstants.SubscriptionStatus.Canceled); + var incompleteSubscription = CreateSubscription("sub_incomplete", "storage-gb-monthly", quantity: 4, status: StripeConstants.SubscriptionStatus.Incomplete); + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(activeSubscription, trialingSubscription, pastDueSubscription, canceledSubscription, incompleteSubscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.Arg() switch + { + "sub_active" => activeSubscription, + "sub_trialing" => trialingSubscription, + "sub_pastdue" => pastDueSubscription, + _ => null + }); + + // Act + await _sut.Execute(context); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription("sub_active", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_trialing", Arg.Any()); + await _stripeFacade.Received(1).UpdateSubscription("sub_pastdue", Arg.Any()); + await _stripeFacade.DidNotReceive().UpdateSubscription("sub_canceled", Arg.Any()); + await _stripeFacade.DidNotReceive().UpdateSubscription("sub_incomplete", Arg.Any()); + } + + #endregion + #region Cancellation Tests [Fact] @@ -598,7 +744,8 @@ public class ReconcileAdditionalStorageJobTests string id, string priceId, long? quantity = null, - Dictionary? metadata = null) + Dictionary? metadata = null, + string status = StripeConstants.SubscriptionStatus.Active) { var price = new Price { Id = priceId }; var item = new SubscriptionItem @@ -611,6 +758,7 @@ public class ReconcileAdditionalStorageJobTests return new Subscription { Id = id, + Status = status, Metadata = metadata, Items = new StripeList { From 04efe402bebc5aef3b06cd40c37474be0d114634 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:12:56 -0600 Subject: [PATCH 22/47] [PM-28128] Create transaction for bank transfer charges (#6691) * Create transaction for charges that were the result of a bank transfer * Claude feedback * Run dotnet format --- .../Services/IStripeEventUtilityService.cs | 2 +- src/Billing/Services/IStripeFacade.cs | 6 ++ .../Implementations/ChargeRefundedHandler.cs | 2 +- .../Implementations/ChargeSucceededHandler.cs | 2 +- .../StripeEventUtilityService.cs | 71 ++++++++++++++++++- .../Services/Implementations/StripeFacade.cs | 8 +++ 6 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/Billing/Services/IStripeEventUtilityService.cs b/src/Billing/Services/IStripeEventUtilityService.cs index a5f536ad11..058f56c887 100644 --- a/src/Billing/Services/IStripeEventUtilityService.cs +++ b/src/Billing/Services/IStripeEventUtilityService.cs @@ -36,7 +36,7 @@ public interface IStripeEventUtilityService /// /// /// /// - Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId); + Task FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId); /// /// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe. diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index f821eeed5f..c7073b9cf9 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -20,6 +20,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + IAsyncEnumerable GetCustomerCashBalanceTransactions( + string customerId, + CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task UpdateCustomer( string customerId, CustomerUpdateOptions customerUpdateOptions = null, diff --git a/src/Billing/Services/Implementations/ChargeRefundedHandler.cs b/src/Billing/Services/Implementations/ChargeRefundedHandler.cs index 905491b6c5..8cc3cb2ce6 100644 --- a/src/Billing/Services/Implementations/ChargeRefundedHandler.cs +++ b/src/Billing/Services/Implementations/ChargeRefundedHandler.cs @@ -38,7 +38,7 @@ public class ChargeRefundedHandler : IChargeRefundedHandler { // Attempt to create a transaction for the charge if it doesn't exist var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge); - var tx = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId); + var tx = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId); try { parentTransaction = await _transactionRepository.CreateAsync(tx); diff --git a/src/Billing/Services/Implementations/ChargeSucceededHandler.cs b/src/Billing/Services/Implementations/ChargeSucceededHandler.cs index bd8ea7def2..20c4dcfa98 100644 --- a/src/Billing/Services/Implementations/ChargeSucceededHandler.cs +++ b/src/Billing/Services/Implementations/ChargeSucceededHandler.cs @@ -46,7 +46,7 @@ public class ChargeSucceededHandler : IChargeSucceededHandler return; } - var transaction = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId); + var transaction = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId); if (!transaction.PaymentMethodType.HasValue) { _logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id); diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index ba3e79abc6..53512427c0 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -124,7 +124,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService /// /// /// /// - public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId) + public async Task FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId) { var transaction = new Transaction { @@ -209,6 +209,24 @@ public class StripeEventUtilityService : IStripeEventUtilityService transaction.PaymentMethodType = PaymentMethodType.BankAccount; transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}"; } + else if (charge.PaymentMethodDetails.CustomerBalance != null) + { + var bankTransferType = await GetFundingBankTransferTypeAsync(charge); + + if (!string.IsNullOrEmpty(bankTransferType)) + { + transaction.PaymentMethodType = PaymentMethodType.BankAccount; + transaction.Details = bankTransferType switch + { + "eu_bank_transfer" => "EU Bank Transfer", + "gb_bank_transfer" => "GB Bank Transfer", + "jp_bank_transfer" => "JP Bank Transfer", + "mx_bank_transfer" => "MX Bank Transfer", + "us_bank_transfer" => "US Bank Transfer", + _ => "Bank Transfer" + }; + } + } break; } @@ -406,4 +424,55 @@ public class StripeEventUtilityService : IStripeEventUtilityService throw; } } + + /// + /// Retrieves the bank transfer type that funded a charge paid via customer balance. + /// + /// The charge to analyze. + /// + /// The bank transfer type (e.g., "us_bank_transfer", "eu_bank_transfer") if the charge was funded + /// by a bank transfer via customer balance, otherwise null. + /// + private async Task GetFundingBankTransferTypeAsync(Charge charge) + { + if (charge is not + { + CustomerId: not null, + PaymentIntentId: not null, + PaymentMethodDetails: { Type: "customer_balance" } + }) + { + return null; + } + + var cashBalanceTransactions = _stripeFacade.GetCustomerCashBalanceTransactions(charge.CustomerId); + + string bankTransferType = null; + var matchingPaymentIntentFound = false; + + await foreach (var cashBalanceTransaction in cashBalanceTransactions) + { + switch (cashBalanceTransaction) + { + case { Type: "funded", Funded: not null }: + { + bankTransferType = cashBalanceTransaction.Funded.BankTransfer.Type; + break; + } + case { Type: "applied_to_payment", AppliedToPayment: not null } + when cashBalanceTransaction.AppliedToPayment.PaymentIntentId == charge.PaymentIntentId: + { + matchingPaymentIntentFound = true; + break; + } + } + + if (matchingPaymentIntentFound && !string.IsNullOrEmpty(bankTransferType)) + { + return bankTransferType; + } + } + + return null; + } } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index bb72091bc6..49cde981cd 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -11,6 +11,7 @@ public class StripeFacade : IStripeFacade { private readonly ChargeService _chargeService = new(); private readonly CustomerService _customerService = new(); + private readonly CustomerCashBalanceTransactionService _customerCashBalanceTransactionService = new(); private readonly EventService _eventService = new(); private readonly InvoiceService _invoiceService = new(); private readonly PaymentMethodService _paymentMethodService = new(); @@ -41,6 +42,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); + public IAsyncEnumerable GetCustomerCashBalanceTransactions( + string customerId, + CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) + => _customerCashBalanceTransactionService.ListAutoPagingAsync(customerId, customerCashBalanceTransactionListOptions, requestOptions, cancellationToken); + public async Task UpdateCustomer( string customerId, CustomerUpdateOptions customerUpdateOptions = null, From 00c4ac2df1aef3e7564424cfed0372e69167f338 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Wed, 17 Dec 2025 09:19:37 -0600 Subject: [PATCH 23/47] [PM-29840] Correcting Auto-Confirm Org Accept User Flow (#6740) * Populating org user userId and adding to allOrgUser list. * Having validator check organization user existence based off email or userid. --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 4 +++- .../AutomaticUserConfirmationPolicyEnforcementValidator.cs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index c763cc0cc2..50f194b578 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -270,7 +270,9 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand ICollection allOrgUsers, User user) { var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( - new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user))) + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, + allOrgUsers.Append(orgUser), + user))) .Match( error => error.Message, _ => string.Empty diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs index 633b84d2b9..e5c980ea24 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -19,7 +19,8 @@ public class AutomaticUserConfirmationPolicyEnforcementValidator( var currentOrganizationUser = request.AllOrganizationUsers .FirstOrDefault(x => x.OrganizationId == request.OrganizationId - && x.UserId == request.User.Id); + // invited users do not have a userId but will have email + && (x.UserId == request.User.Id || x.Email == request.User.Email)); if (currentOrganizationUser is null) { From bbe682dae92097fd14ebc78d7f23c80a0eea46ef Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:34:12 -0500 Subject: [PATCH 24/47] refactor(IdentityTokenResponse): [Auth/PM-3287] Remove deprecated resetMasterPassword property from IdentityTokenResponse (#6676) --- .../RequestValidators/BaseRequestValidator.cs | 1 - .../CustomTokenRequestValidator.cs | 17 ----------------- .../Endpoints/IdentityServerTests.cs | 1 - 3 files changed, 19 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 0bdf1d89c2..b0f3311b2c 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -671,7 +671,6 @@ public abstract class BaseRequestValidator where T : class customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); customResponse.Add("ForcePasswordReset", user.ForcePasswordReset); - customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); customResponse.Add("Kdf", (byte)user.Kdf); customResponse.Add("KdfIterations", user.KdfIterations); customResponse.Add("KdfMemory", user.KdfMemory); diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 38a4813ecd..5eee4199b2 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -4,7 +4,6 @@ using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.IdentityServer; -using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -155,23 +154,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator var root = body.RootElement; AssertRefreshTokenExists(root); AssertHelper.AssertJsonProperty(root, "ForcePasswordReset", JsonValueKind.False); - AssertHelper.AssertJsonProperty(root, "ResetMasterPassword", JsonValueKind.False); var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32(); Assert.Equal(0, kdf); var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32(); From 886ba9ae6d4da5d8796658cf7c7226c2cd7ec65e Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:43:53 -0500 Subject: [PATCH 25/47] Refactor IntegrationHandlerResult to provide more detail around failures (#6736) * Refactor IntegrationHandlerResult to provide more detail around failures * ServiceUnavailable now retryable, more explicit http status handling, more consistency with different handlers, additional xmldocs * Address PR feedback --- .../IntegrationFailureCategory.cs | 37 +++++ .../IntegrationHandlerResult.cs | 82 ++++++++++- .../Services/IIntegrationHandler.cs | 117 ++++++++++------ ...ureServiceBusIntegrationListenerService.cs | 11 ++ .../RabbitMqIntegrationListenerService.cs | 22 ++- .../SlackIntegrationHandler.cs | 68 +++++++--- .../TeamsIntegrationHandler.cs | 51 +++++-- .../IntegrationHandlerResultTests.cs | 128 ++++++++++++++++++ ...rviceBusIntegrationListenerServiceTests.cs | 35 +++-- .../DatadogIntegrationHandlerTests.cs | 2 +- .../Services/IntegrationHandlerTests.cs | 108 ++++++++++++++- ...RabbitMqIntegrationListenerServiceTests.cs | 25 ++-- .../Services/SlackIntegrationHandlerTests.cs | 4 +- .../Services/TeamsIntegrationHandlerTests.cs | 82 ++++++++++- .../WebhookIntegrationHandlerTests.cs | 4 +- 15 files changed, 663 insertions(+), 113 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs create mode 100644 test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs new file mode 100644 index 0000000000..544e671d51 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFailureCategory.cs @@ -0,0 +1,37 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +/// +/// Categories of event integration failures used for classification and retry logic. +/// +public enum IntegrationFailureCategory +{ + /// + /// Service is temporarily unavailable (503, upstream outage, maintenance). + /// + ServiceUnavailable, + + /// + /// Authentication failed (401, 403, invalid_auth, token issues). + /// + AuthenticationFailed, + + /// + /// Configuration error (invalid config, channel_not_found, etc.). + /// + ConfigurationError, + + /// + /// Rate limited (429, rate_limited). + /// + RateLimited, + + /// + /// Transient error (timeouts, 500, network errors). + /// + TransientError, + + /// + /// Permanent failure unrelated to authentication/config (e.g., unrecoverable payload/format issue). + /// + PermanentFailure +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs index 8db054561b..375f2489cb 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -1,16 +1,84 @@ namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +/// +/// Represents the result of an integration handler operation, including success status, +/// failure categorization, and retry metadata. Use the factory method +/// for successful operations or for failures with automatic retry-ability +/// determination based on the failure category. +/// public class IntegrationHandlerResult { - public IntegrationHandlerResult(bool success, IIntegrationMessage message) + /// + /// True if the integration send succeeded, false otherwise. + /// + public bool Success { get; } + + /// + /// The integration message that was processed. + /// + public IIntegrationMessage Message { get; } + + /// + /// Optional UTC date/time indicating when a failed operation should be retried. + /// Will be used by the retry queue to delay re-sending the message. + /// Usually set based on the Retry-After header from rate-limited responses. + /// + public DateTime? DelayUntilDate { get; private init; } + + /// + /// Category of the failure. Null for successful results. + /// + public IntegrationFailureCategory? Category { get; private init; } + + /// + /// Detailed failure reason or error message. Empty for successful results. + /// + public string? FailureReason { get; private init; } + + /// + /// Indicates whether the operation is retryable. + /// Computed from the failure category. + /// + public bool Retryable => Category switch + { + IntegrationFailureCategory.RateLimited => true, + IntegrationFailureCategory.TransientError => true, + IntegrationFailureCategory.ServiceUnavailable => true, + IntegrationFailureCategory.AuthenticationFailed => false, + IntegrationFailureCategory.ConfigurationError => false, + IntegrationFailureCategory.PermanentFailure => false, + null => false, + _ => false + }; + + /// + /// Creates a successful result. + /// + public static IntegrationHandlerResult Succeed(IIntegrationMessage message) + { + return new IntegrationHandlerResult(success: true, message: message); + } + + /// + /// Creates a failed result with a failure category and reason. + /// + public static IntegrationHandlerResult Fail( + IIntegrationMessage message, + IntegrationFailureCategory category, + string failureReason, + DateTime? delayUntil = null) + { + return new IntegrationHandlerResult(success: false, message: message) + { + Category = category, + FailureReason = failureReason, + DelayUntilDate = delayUntil + }; + } + + private IntegrationHandlerResult(bool success, IIntegrationMessage message) { Success = success; Message = message; } - - public bool Success { get; set; } = false; - public bool Retryable { get; set; } = false; - public IIntegrationMessage Message { get; set; } - public DateTime? DelayUntilDate { get; set; } - public string FailureReason { get; set; } = string.Empty; } diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs index bb10dc01b9..c36081cb52 100644 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -29,46 +29,87 @@ public abstract class IntegrationHandlerBase : IIntegrationHandler IntegrationMessage message, TimeProvider timeProvider) { - var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); - - if (response.IsSuccessStatusCode) return result; - - switch (response.StatusCode) + if (response.IsSuccessStatusCode) { - case HttpStatusCode.TooManyRequests: - case HttpStatusCode.RequestTimeout: - case HttpStatusCode.InternalServerError: - case HttpStatusCode.BadGateway: - case HttpStatusCode.ServiceUnavailable: - case HttpStatusCode.GatewayTimeout: - result.Retryable = true; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; - - if (response.Headers.TryGetValues("Retry-After", out var values)) - { - var value = values.FirstOrDefault(); - if (int.TryParse(value, out var seconds)) - { - // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; - } - else if (DateTimeOffset.TryParseExact(value, - "r", // "r" is the round-trip format: RFC1123 - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var retryDate)) - { - // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. - result.DelayUntilDate = retryDate.UtcDateTime; - } - } - break; - default: - result.Retryable = false; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; - break; + return IntegrationHandlerResult.Succeed(message); } - return result; + var category = ClassifyHttpStatusCode(response.StatusCode); + var failureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; + + if (category is not (IntegrationFailureCategory.RateLimited + or IntegrationFailureCategory.TransientError + or IntegrationFailureCategory.ServiceUnavailable) || + !response.Headers.TryGetValues("Retry-After", out var values) + ) + { + return IntegrationHandlerResult.Fail(message: message, category: category, failureReason: failureReason); + } + + // Handle Retry-After header for rate-limited and retryable errors + DateTime? delayUntil = null; + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var seconds)) + { + // Retry-after was specified in seconds + delayUntil = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; + } + else if (DateTimeOffset.TryParseExact(value, + "r", // "r" is the round-trip format: RFC1123 + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var retryDate)) + { + // Retry-after was specified as a date + delayUntil = retryDate.UtcDateTime; + } + + return IntegrationHandlerResult.Fail( + message, + category, + failureReason, + delayUntil + ); + } + + /// + /// Classifies an as an to drive + /// retry behavior and operator-facing failure reporting. + /// + /// The HTTP status code. + /// The corresponding . + protected static IntegrationFailureCategory ClassifyHttpStatusCode(HttpStatusCode statusCode) + { + var explicitCategory = statusCode switch + { + HttpStatusCode.Unauthorized => IntegrationFailureCategory.AuthenticationFailed, + HttpStatusCode.Forbidden => IntegrationFailureCategory.AuthenticationFailed, + HttpStatusCode.NotFound => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.Gone => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.MovedPermanently => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.TemporaryRedirect => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.PermanentRedirect => IntegrationFailureCategory.ConfigurationError, + HttpStatusCode.TooManyRequests => IntegrationFailureCategory.RateLimited, + HttpStatusCode.RequestTimeout => IntegrationFailureCategory.TransientError, + HttpStatusCode.InternalServerError => IntegrationFailureCategory.TransientError, + HttpStatusCode.BadGateway => IntegrationFailureCategory.TransientError, + HttpStatusCode.GatewayTimeout => IntegrationFailureCategory.TransientError, + HttpStatusCode.ServiceUnavailable => IntegrationFailureCategory.ServiceUnavailable, + HttpStatusCode.NotImplemented => IntegrationFailureCategory.PermanentFailure, + _ => (IntegrationFailureCategory?)null + }; + + if (explicitCategory is not null) + { + return explicitCategory.Value; + } + + return (int)statusCode switch + { + >= 300 and <= 399 => IntegrationFailureCategory.ConfigurationError, + >= 400 and <= 499 => IntegrationFailureCategory.ConfigurationError, + >= 500 and <= 599 => IntegrationFailureCategory.ServiceUnavailable, + _ => IntegrationFailureCategory.ServiceUnavailable + }; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs index 633a53296b..c97c5f7efe 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs @@ -85,6 +85,17 @@ public class AzureServiceBusIntegrationListenerService : Backgro { // Non-recoverable failure or exceeded the max number of retries // Return false to indicate this message should be dead-lettered + _logger.LogWarning( + "Integration failure - non-recoverable error or max retries exceeded. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason, + message.RetryCount, + _maxRetries); return false; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs index b426032c92..0762edc040 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs @@ -106,14 +106,32 @@ public class RabbitMqIntegrationListenerService : BackgroundServ { // Exceeded the max number of retries; fail and send to dead letter queue await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); - _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); + _logger.LogWarning( + "Integration failure - max retries exceeded. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason, + message.RetryCount, + _maxRetries); } } else { // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); - _logger.LogWarning("Non-retryable failure. Sent to DLQ."); + _logger.LogWarning( + "Integration failure - non-retryable. " + + "MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " + + "FailureCategory: {Category}, Reason: {Reason}", + message.MessageId, + message.IntegrationType, + message.OrganizationId, + result.Category, + result.FailureReason); } // Message has been sent to retry or dead letter queues. diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 16c756c8c4..e681140afe 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -6,15 +6,6 @@ public class SlackIntegrationHandler( ISlackService slackService) : IntegrationHandlerBase { - private static readonly HashSet _retryableErrors = new(StringComparer.Ordinal) - { - "internal_error", - "message_limit_exceeded", - "rate_limited", - "ratelimited", - "service_unavailable" - }; - public override async Task HandleAsync(IntegrationMessage message) { var slackResponse = await slackService.SendSlackMessageByChannelIdAsync( @@ -25,24 +16,61 @@ public class SlackIntegrationHandler( if (slackResponse is null) { - return new IntegrationHandlerResult(success: false, message: message) - { - FailureReason = "Slack response was null" - }; + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + "Slack response was null" + ); } if (slackResponse.Ok) { - return new IntegrationHandlerResult(success: true, message: message); + return IntegrationHandlerResult.Succeed(message); } - var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error }; + var category = ClassifySlackError(slackResponse.Error); + return IntegrationHandlerResult.Fail( + message, + category, + slackResponse.Error + ); + } - if (_retryableErrors.Contains(slackResponse.Error)) + /// + /// Classifies a Slack API error code string as an to drive + /// retry behavior and operator-facing failure reporting. + /// + /// + /// + /// Slack responses commonly return an error string when ok is false. This method maps + /// known Slack error codes to failure categories. + /// + /// + /// Any unrecognized error codes default to to avoid + /// incorrectly marking new/unknown Slack failures as non-retryable. + /// + /// + /// The Slack error code string (e.g. invalid_auth, rate_limited). + /// The corresponding . + private static IntegrationFailureCategory ClassifySlackError(string error) + { + return error switch { - result.Retryable = true; - } - - return result; + "invalid_auth" => IntegrationFailureCategory.AuthenticationFailed, + "access_denied" => IntegrationFailureCategory.AuthenticationFailed, + "token_expired" => IntegrationFailureCategory.AuthenticationFailed, + "token_revoked" => IntegrationFailureCategory.AuthenticationFailed, + "account_inactive" => IntegrationFailureCategory.AuthenticationFailed, + "not_authed" => IntegrationFailureCategory.AuthenticationFailed, + "channel_not_found" => IntegrationFailureCategory.ConfigurationError, + "is_archived" => IntegrationFailureCategory.ConfigurationError, + "rate_limited" => IntegrationFailureCategory.RateLimited, + "ratelimited" => IntegrationFailureCategory.RateLimited, + "message_limit_exceeded" => IntegrationFailureCategory.RateLimited, + "internal_error" => IntegrationFailureCategory.TransientError, + "service_unavailable" => IntegrationFailureCategory.ServiceUnavailable, + "fatal_error" => IntegrationFailureCategory.ServiceUnavailable, + _ => IntegrationFailureCategory.TransientError + }; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs index 41d60bd69c..9e3645a99f 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Rest; namespace Bit.Core.Services; @@ -18,24 +19,48 @@ public class TeamsIntegrationHandler( channelId: message.Configuration.ChannelId ); - return new IntegrationHandlerResult(success: true, message: message); + return IntegrationHandlerResult.Succeed(message); } catch (HttpOperationException ex) { - var result = new IntegrationHandlerResult(success: false, message: message); - var statusCode = (int)ex.Response.StatusCode; - result.Retryable = statusCode is 429 or >= 500 and < 600; - result.FailureReason = ex.Message; - - return result; + var category = ClassifyHttpStatusCode(ex.Response.StatusCode); + return IntegrationHandlerResult.Fail( + message, + category, + ex.Message + ); + } + catch (ArgumentException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + ex.Message + ); + } + catch (UriFormatException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + ex.Message + ); + } + catch (JsonException ex) + { + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.PermanentFailure, + ex.Message + ); } catch (Exception ex) { - var result = new IntegrationHandlerResult(success: false, message: message); - result.Retryable = false; - result.FailureReason = ex.Message; - - return result; + return IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + ex.Message + ); } } } diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs new file mode 100644 index 0000000000..6925a978eb --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResultTests.cs @@ -0,0 +1,128 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationHandlerResultTests +{ + [Theory, BitAutoData] + public void Succeed_SetsSuccessTrue_CategoryNull(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Succeed(message); + + Assert.True(result.Success); + Assert.Null(result.Category); + Assert.Equal(message, result.Message); + Assert.Null(result.FailureReason); + } + + [Theory, BitAutoData] + public void Fail_WithCategory_SetsSuccessFalse_CategorySet(IntegrationMessage message) + { + var category = IntegrationFailureCategory.AuthenticationFailed; + var failureReason = "Invalid credentials"; + + var result = IntegrationHandlerResult.Fail(message, category, failureReason); + + Assert.False(result.Success); + Assert.Equal(category, result.Category); + Assert.Equal(failureReason, result.FailureReason); + Assert.Equal(message, result.Message); + } + + [Theory, BitAutoData] + public void Fail_WithDelayUntil_SetsDelayUntilDate(IntegrationMessage message) + { + var delayUntil = DateTime.UtcNow.AddMinutes(5); + + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.RateLimited, + "Rate limited", + delayUntil + ); + + Assert.Equal(delayUntil, result.DelayUntilDate); + } + + [Theory, BitAutoData] + public void Retryable_RateLimited_ReturnsTrue(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.RateLimited, + "Rate limited" + ); + + Assert.True(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_TransientError_ReturnsTrue(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.TransientError, + "Temporary network issue" + ); + + Assert.True(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_AuthenticationFailed_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.AuthenticationFailed, + "Invalid token" + ); + + Assert.False(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_ConfigurationError_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ConfigurationError, + "Channel not found" + ); + + Assert.False(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_ServiceUnavailable_ReturnsTrue(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.ServiceUnavailable, + "Service is down" + ); + + Assert.True(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_PermanentFailure_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Fail( + message, + IntegrationFailureCategory.PermanentFailure, + "Permanent failure" + ); + + Assert.False(result.Retryable); + } + + [Theory, BitAutoData] + public void Retryable_SuccessCase_ReturnsFalse(IntegrationMessage message) + { + var result = IntegrationHandlerResult.Succeed(message); + + Assert.False(result.Retryable); + } +} diff --git a/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs b/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs index 23627f3962..9e46a3a99a 100644 --- a/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/AzureServiceBusIntegrationListenerServiceTests.cs @@ -78,8 +78,10 @@ public class AzureServiceBusIntegrationListenerServiceTests var sutProvider = GetSutProvider(); message.RetryCount = 0; - var result = new IntegrationHandlerResult(false, message); - result.Retryable = false; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.AuthenticationFailed, // NOT retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -89,6 +91,12 @@ public class AzureServiceBusIntegrationListenerServiceTests await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson())); await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any()); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - non-recoverable error or max retries exceeded.")), + Arg.Any(), + Arg.Any>()); } [Theory, BitAutoData] @@ -96,9 +104,10 @@ public class AzureServiceBusIntegrationListenerServiceTests { var sutProvider = GetSutProvider(); message.RetryCount = _config.MaxRetries; - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; - + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -108,6 +117,12 @@ public class AzureServiceBusIntegrationListenerServiceTests await _handler.Received(1).HandleAsync(Arg.Is(expected.ToJson())); await _serviceBusService.DidNotReceiveWithAnyArgs().PublishToRetryAsync(Arg.Any()); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - non-recoverable error or max retries exceeded.")), + Arg.Any(), + Arg.Any>()); } [Theory, BitAutoData] @@ -116,8 +131,10 @@ public class AzureServiceBusIntegrationListenerServiceTests var sutProvider = GetSutProvider(); message.RetryCount = 0; - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -133,7 +150,7 @@ public class AzureServiceBusIntegrationListenerServiceTests public async Task HandleMessageAsync_SuccessfulResult_Succeeds(IntegrationMessage message) { var sutProvider = GetSutProvider(); - var result = new IntegrationHandlerResult(true, message); + var result = IntegrationHandlerResult.Succeed(message); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -156,7 +173,7 @@ public class AzureServiceBusIntegrationListenerServiceTests _logger.Received(1).Log( LogLevel.Error, Arg.Any(), - Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains("Unhandled error processing ASB message")), Arg.Any(), Arg.Any>()); diff --git a/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs index 5f0a9915bf..9cb21f012a 100644 --- a/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs @@ -51,7 +51,7 @@ public class DatadogIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); - Assert.Empty(result.FailureReason); + Assert.Null(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName)) diff --git a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs index f6f587cfd7..b3bbcb7ef2 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Net; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Services; using Xunit; @@ -7,7 +8,6 @@ namespace Bit.Core.Test.Services; public class IntegrationHandlerTests { - [Fact] public async Task HandleAsync_ConvertsJsonToTypedIntegrationMessage() { @@ -33,13 +33,113 @@ public class IntegrationHandlerTests Assert.Equal(expected.IntegrationType, typedResult.IntegrationType); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + public void ClassifyHttpStatusCode_AuthenticationFailed(HttpStatusCode code) + { + Assert.Equal( + IntegrationFailureCategory.AuthenticationFailed, + TestIntegrationHandler.Classify(code)); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Gone)] + [InlineData(HttpStatusCode.MovedPermanently)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + [InlineData(HttpStatusCode.PermanentRedirect)] + public void ClassifyHttpStatusCode_ConfigurationError(HttpStatusCode code) + { + Assert.Equal( + IntegrationFailureCategory.ConfigurationError, + TestIntegrationHandler.Classify(code)); + } + + [Fact] + public void ClassifyHttpStatusCode_TooManyRequests_IsRateLimited() + { + Assert.Equal( + IntegrationFailureCategory.RateLimited, + TestIntegrationHandler.Classify(HttpStatusCode.TooManyRequests)); + } + + [Fact] + public void ClassifyHttpStatusCode_RequestTimeout_IsTransient() + { + Assert.Equal( + IntegrationFailureCategory.TransientError, + TestIntegrationHandler.Classify(HttpStatusCode.RequestTimeout)); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.GatewayTimeout)] + public void ClassifyHttpStatusCode_Common5xx_AreTransient(HttpStatusCode code) + { + Assert.Equal( + IntegrationFailureCategory.TransientError, + TestIntegrationHandler.Classify(code)); + } + + [Fact] + public void ClassifyHttpStatusCode_ServiceUnavailable_IsServiceUnavailable() + { + Assert.Equal( + IntegrationFailureCategory.ServiceUnavailable, + TestIntegrationHandler.Classify(HttpStatusCode.ServiceUnavailable)); + } + + [Fact] + public void ClassifyHttpStatusCode_NotImplemented_IsPermanentFailure() + { + Assert.Equal( + IntegrationFailureCategory.PermanentFailure, + TestIntegrationHandler.Classify(HttpStatusCode.NotImplemented)); + } + + [Fact] + public void FClassifyHttpStatusCode_Unhandled3xx_IsConfigurationError() + { + Assert.Equal( + IntegrationFailureCategory.ConfigurationError, + TestIntegrationHandler.Classify(HttpStatusCode.Found)); + } + + [Fact] + public void ClassifyHttpStatusCode_Unhandled4xx_IsConfigurationError() + { + Assert.Equal( + IntegrationFailureCategory.ConfigurationError, + TestIntegrationHandler.Classify(HttpStatusCode.BadRequest)); + } + + [Fact] + public void ClassifyHttpStatusCode_Unhandled5xx_IsServiceUnavailable() + { + Assert.Equal( + IntegrationFailureCategory.ServiceUnavailable, + TestIntegrationHandler.Classify(HttpStatusCode.HttpVersionNotSupported)); + } + + [Fact] + public void ClassifyHttpStatusCode_UnknownCode_DefaultsToServiceUnavailable() + { + // cast an out-of-range value to ensure default path is stable + Assert.Equal( + IntegrationFailureCategory.ServiceUnavailable, + TestIntegrationHandler.Classify((HttpStatusCode)799)); + } + private class TestIntegrationHandler : IntegrationHandlerBase { public override Task HandleAsync( IntegrationMessage message) { - var result = new IntegrationHandlerResult(success: true, message: message); - return Task.FromResult(result); + return Task.FromResult(IntegrationHandlerResult.Succeed(message: message)); } + + public static IntegrationFailureCategory Classify(HttpStatusCode code) => ClassifyHttpStatusCode(code); } } diff --git a/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs b/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs index 5fcd121252..71985889f8 100644 --- a/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/RabbitMqIntegrationListenerServiceTests.cs @@ -86,8 +86,10 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(false, message); - result.Retryable = false; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.AuthenticationFailed, // NOT retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -105,7 +107,7 @@ public class RabbitMqIntegrationListenerServiceTests _logger.Received().Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => (o.ToString() ?? "").Contains("Non-retryable failure")), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - non-retryable.")), Arg.Any(), Arg.Any>()); @@ -133,8 +135,10 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -151,7 +155,7 @@ public class RabbitMqIntegrationListenerServiceTests _logger.Received().Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => (o.ToString() ?? "").Contains("Max retry attempts reached")), + Arg.Is(o => (o.ToString() ?? "").Contains("Integration failure - max retries exceeded.")), Arg.Any(), Arg.Any>()); @@ -179,9 +183,10 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(false, message); - result.Retryable = true; - result.DelayUntilDate = _now.AddMinutes(1); + var result = IntegrationHandlerResult.Fail( + message: message, + category: IntegrationFailureCategory.TransientError, // Retryable + failureReason: "403"); _handler.HandleAsync(Arg.Any()).Returns(result); var expected = IntegrationMessage.FromJson(message.ToJson()); @@ -220,7 +225,7 @@ public class RabbitMqIntegrationListenerServiceTests new BasicProperties(), body: Encoding.UTF8.GetBytes(message.ToJson()) ); - var result = new IntegrationHandlerResult(true, message); + var result = IntegrationHandlerResult.Succeed(message); _handler.HandleAsync(Arg.Any()).Returns(result); await sutProvider.Sut.ProcessReceivedMessageAsync(eventArgs, cancellationToken); diff --git a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs index e2e459ceb3..e455100995 100644 --- a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs @@ -110,7 +110,7 @@ public class SlackIntegrationHandlerTests } [Fact] - public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure() + public async Task HandleAsync_NullResponse_ReturnsRetryableFailure() { var sutProvider = GetSutProvider(); var message = new IntegrationMessage() @@ -126,7 +126,7 @@ public class SlackIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); - Assert.False(result.Retryable); + Assert.True(result.Retryable); // Null response is classified as TransientError (retryable) Assert.Equal("Slack response was null", result.FailureReason); Assert.Equal(result.Message, message); diff --git a/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs index b744a6aa69..11056ec2cc 100644 --- a/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -42,9 +43,77 @@ public class TeamsIntegrationHandlerTests ); } + [Theory, BitAutoData] + public async Task HandleAsync_ArgumentException_ReturnsConfigurationError(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new ArgumentException("argument error")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.ConfigurationError, result.Category); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } [Theory, BitAutoData] - public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage message) + public async Task HandleAsync_JsonException_ReturnsPermanentFailure(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new JsonException("JSON error")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.PermanentFailure, result.Category); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_UriFormatException_ReturnsConfigurationError(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new UriFormatException("Bad URI")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.ConfigurationError, result.Category); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_HttpExceptionForbidden_ReturnsAuthenticationFailed(IntegrationMessage message) { var sutProvider = GetSutProvider(); message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); @@ -62,6 +131,7 @@ public class TeamsIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.AuthenticationFailed, result.Category); Assert.False(result.Retryable); Assert.Equal(result.Message, message); @@ -73,7 +143,7 @@ public class TeamsIntegrationHandlerTests } [Theory, BitAutoData] - public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage message) + public async Task HandleAsync_HttpExceptionTooManyRequests_ReturnsRateLimited(IntegrationMessage message) { var sutProvider = GetSutProvider(); message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); @@ -92,6 +162,7 @@ public class TeamsIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); + Assert.Equal(IntegrationFailureCategory.RateLimited, result.Category); Assert.True(result.Retryable); Assert.Equal(result.Message, message); @@ -103,7 +174,7 @@ public class TeamsIntegrationHandlerTests } [Theory, BitAutoData] - public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage message) + public async Task HandleAsync_UnknownException_ReturnsTransientError(IntegrationMessage message) { var sutProvider = GetSutProvider(); message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); @@ -114,7 +185,8 @@ public class TeamsIntegrationHandlerTests var result = await sutProvider.Sut.HandleAsync(message); Assert.False(result.Success); - Assert.False(result.Retryable); + Assert.Equal(IntegrationFailureCategory.TransientError, result.Category); + Assert.True(result.Retryable); Assert.Equal(result.Message, message); await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( diff --git a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs index 53a3598d47..05aa46681a 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs @@ -51,7 +51,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); - Assert.Empty(result.FailureReason); + Assert.Null(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) @@ -79,7 +79,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); - Assert.Empty(result.FailureReason); + Assert.Null(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) From de504d800b0513c5dcddc8e23ba386d31c7a7ea1 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Wed, 17 Dec 2025 11:34:17 -0600 Subject: [PATCH 26/47] [PM-24055] - Collection Users and Groups null on Public response (#6713) * Integration test around getting and saving collection with group/user permissions * This adds groups to the collections returned. * Added new stored procedures so we don't accidentally wipe out access due to null parameters. * wrapping all calls in transaction in the event that there is an error. --- .../Models/Response/GroupResponseModel.cs | 7 + .../Response/CollectionResponseModel.cs | 7 + .../Controllers/CollectionsController.cs | 9 +- .../Repositories/CollectionRepository.cs | 116 ++++++++++++-- .../Collection_UpdateWithGroups.sql | 74 +++++++++ .../Collection_UpdateWithUsers.sql | 74 +++++++++ .../Public/CollectionsControllerTests.cs | 117 ++++++++++++++ .../Helpers/OrganizationTestHelpers.cs | 6 +- .../CollectionRepositoryReplaceTests.cs | 65 ++++++++ ...10_00_AddGroupAndUserCollectionUpdates.sql | 151 ++++++++++++++++++ 10 files changed, 609 insertions(+), 17 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroups.sql create mode 100644 src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql create mode 100644 test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs create mode 100644 util/Migrator/DbScripts/2025-12-10_00_AddGroupAndUserCollectionUpdates.sql diff --git a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs index c12616b4cc..e164f3c4ea 100644 --- a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; @@ -13,6 +14,12 @@ namespace Bit.Api.AdminConsole.Public.Models.Response; /// public class GroupResponseModel : GroupBaseModel, IResponseModel { + [JsonConstructor] + public GroupResponseModel() + { + + } + public GroupResponseModel(Group group, IEnumerable collections) { if (group == null) diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 04ae565a27..9e830aeea8 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Data; @@ -13,6 +14,12 @@ namespace Bit.Api.Models.Public.Response; /// public class CollectionResponseModel : CollectionBaseModel, IResponseModel { + [JsonConstructor] + public CollectionResponseModel() + { + + } + public CollectionResponseModel(Collection collection, IEnumerable groups) { if (collection == null) diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index 8615113906..a567062a5e 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -65,10 +65,11 @@ public class CollectionsController : Controller [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync( - _currentContext.OrganizationId.Value); - // TODO: Get all CollectionGroup associations for the organization and marry them up here for the response. - var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null)); + var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value); + + var collectionResponses = collections.Select(c => + new CollectionResponseModel(c.Item1, c.Item2.Groups)); + var response = new ListResponseModel(collectionResponses); return new JsonResult(response); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index c2a59f75aa..9985b41d56 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -226,7 +226,6 @@ public class CollectionRepository : Repository, ICollectionRep { obj.SetNewId(); - var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj))!; objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); @@ -243,18 +242,52 @@ public class CollectionRepository : Repository, ICollectionRep public async Task ReplaceAsync(Collection obj, IEnumerable? groups, IEnumerable? users) { - var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj))!; - - objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); - objWithGroupsAndUsers.Users = users != null ? users.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); - - using (var connection = new SqlConnection(ConnectionString)) + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + try { - var results = await connection.ExecuteAsync( - $"[{Schema}].[Collection_UpdateWithGroupsAndUsers]", - objWithGroupsAndUsers, - commandType: CommandType.StoredProcedure); + if (groups == null && users == null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_Update]", + obj, + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + else if (groups != null && users == null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_UpdateWithGroups]", + new CollectionWithGroups(obj, groups), + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + else if (groups == null && users != null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_UpdateWithUsers]", + new CollectionWithUsers(obj, users), + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + else if (groups != null && users != null) + { + await connection.ExecuteAsync( + $"[{Schema}].[Collection_UpdateWithGroupsAndUsers]", + new CollectionWithGroupsAndUsers(obj, groups, users), + commandType: CommandType.StoredProcedure, + transaction: transaction); + } + + await transaction.CommitAsync(); } + catch + { + await transaction.RollbackAsync(); + throw; + } + } public async Task DeleteManyAsync(IEnumerable collectionIds) @@ -424,9 +457,70 @@ public class CollectionRepository : Repository, ICollectionRep public class CollectionWithGroupsAndUsers : Collection { + public CollectionWithGroupsAndUsers() { } + + public CollectionWithGroupsAndUsers(Collection collection, + IEnumerable groups, + IEnumerable users) + { + Id = collection.Id; + Name = collection.Name; + OrganizationId = collection.OrganizationId; + CreationDate = collection.CreationDate; + RevisionDate = collection.RevisionDate; + Type = collection.Type; + ExternalId = collection.ExternalId; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + Groups = groups.ToArrayTVP(); + Users = users.ToArrayTVP(); + } + [DisallowNull] public DataTable? Groups { get; set; } [DisallowNull] public DataTable? Users { get; set; } } + + public class CollectionWithGroups : Collection + { + public CollectionWithGroups() { } + + public CollectionWithGroups(Collection collection, IEnumerable groups) + { + Id = collection.Id; + Name = collection.Name; + OrganizationId = collection.OrganizationId; + CreationDate = collection.CreationDate; + RevisionDate = collection.RevisionDate; + Type = collection.Type; + ExternalId = collection.ExternalId; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + Groups = groups.ToArrayTVP(); + } + + [DisallowNull] + public DataTable? Groups { get; set; } + } + + public class CollectionWithUsers : Collection + { + public CollectionWithUsers() { } + + public CollectionWithUsers(Collection collection, IEnumerable users) + { + + Id = collection.Id; + Name = collection.Name; + OrganizationId = collection.OrganizationId; + CreationDate = collection.CreationDate; + RevisionDate = collection.RevisionDate; + Type = collection.Type; + ExternalId = collection.ExternalId; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; + Users = users.ToArrayTVP(); + } + + [DisallowNull] + public DataTable? Users { get; set; } + } } diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroups.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroups.sql new file mode 100644 index 0000000000..7f7fc2e0d7 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithGroups.sql @@ -0,0 +1,74 @@ +CREATE PROCEDURE [dbo].[Collection_UpdateWithGroups] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Groups + -- Delete groups that are no longer in source + DELETE + cg + FROM + [dbo].[CollectionGroup] cg + LEFT JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE + cg + SET + cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM + [dbo].[CollectionGroup] cg + INNER JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND ( + cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage + ); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM + @Groups g + INNER JOIN + [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN + [dbo].[CollectionGroup] cg ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE + grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql new file mode 100644 index 0000000000..60fccc51d5 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_UpdateWithUsers.sql @@ -0,0 +1,74 @@ +CREATE PROCEDURE [dbo].[Collection_UpdateWithUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Users + -- Delete users that are no longer in source + DELETE + cu + FROM + [dbo].[CollectionUser] cu + LEFT JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE + cu + SET + cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM + [dbo].[CollectionUser] cu + INNER JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND ( + cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage + ); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM + @Users u + INNER JOIN + [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN + [dbo].[CollectionUser] cu ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE + ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END diff --git a/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs new file mode 100644 index 0000000000..a729abb849 --- /dev/null +++ b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs @@ -0,0 +1,117 @@ +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Public.Request; +using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Xunit; + +namespace Bit.Api.IntegrationTest.Controllers.Public; + +public class CollectionsControllerTests : IClassFixture, IAsyncLifetime +{ + + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private string _ownerEmail = null!; + private Organization _organization = null!; + + public CollectionsControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(_ => { }); + _factory.SubstituteService(_ => { }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 10, + paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task CreateCollectionWithMultipleUsersAndVariedPermissions_Success() + { + // Arrange + _organization.AllowAdminAccessToAllCollectionItems = true; + await _factory.GetService().UpsertAsync(_organization); + + var groupRepository = _factory.GetService(); + var group = await groupRepository.CreateAsync(new Group + { + OrganizationId = _organization.Id, + Name = "CollectionControllerTests.CreateCollectionWithMultipleUsersAndVariedPermissions_Success", + ExternalId = $"CollectionControllerTests.CreateCollectionWithMultipleUsersAndVariedPermissions_Success{Guid.NewGuid()}", + }); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + _organization.Id, + OrganizationUserType.User); + + var collection = await OrganizationTestHelpers.CreateCollectionAsync( + _factory, + _organization.Id, + "Shared Collection with a group", + externalId: "shared-collection-with-group", + groups: + [ + new CollectionAccessSelection { Id = group.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ], + users: + [ + new CollectionAccessSelection { Id = user.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ]); + + var getCollectionsResponse = await _client.GetFromJsonAsync>("public/collections"); + var getCollectionResponse = await _client.GetFromJsonAsync($"public/collections/{collection.Id}"); + + var firstCollection = getCollectionsResponse.Data.First(x => x.ExternalId == "shared-collection-with-group"); + + var update = new CollectionUpdateRequestModel + { + ExternalId = firstCollection.ExternalId, + Groups = firstCollection.Groups?.Select(x => new AssociationWithPermissionsRequestModel + { + Id = x.Id, + ReadOnly = x.ReadOnly, + HidePasswords = x.HidePasswords, + Manage = x.Manage + }), + }; + + await _client.PutAsJsonAsync($"public/collections/{firstCollection.Id}", update); + + var result = await _factory.GetService() + .GetByIdWithAccessAsync(firstCollection.Id); + + Assert.NotNull(result); + Assert.NotEmpty(result.Item2.Groups); + Assert.NotEmpty(result.Item2.Users); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index bcde370b24..887ef989ce 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -159,14 +159,16 @@ public static class OrganizationTestHelpers Guid organizationId, string name, IEnumerable? users = null, - IEnumerable? groups = null) + IEnumerable? groups = null, + string? externalId = null) { var collectionRepository = factory.GetService(); var collection = new Collection { OrganizationId = organizationId, Name = name, - Type = CollectionType.SharedCollection + Type = CollectionType.SharedCollection, + ExternalId = externalId }; await collectionRepository.CreateAsync(collection, groups, users); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs index df01276493..de4fd53a68 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs @@ -144,4 +144,69 @@ public class CollectionRepositoryReplaceTests await userRepository.DeleteAsync(user); await organizationRepository.DeleteAsync(organization); } + + [Theory, DatabaseData] + public async Task ReplaceAsync_WhenNotPassingGroupsOrUsers_DoesNotDeleteAccess( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository, + ICollectionRepository collectionRepository) + { + // Arrange + var organization = await organizationRepository.CreateTestOrganizationAsync(); + + var user1 = await userRepository.CreateTestUserAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); + + var user2 = await userRepository.CreateTestUserAsync(); + var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user2); + + var group1 = await groupRepository.CreateTestGroupAsync(organization); + var group2 = await groupRepository.CreateTestGroupAsync(organization); + + var collection = new Collection + { + Name = "Test Collection Name", + OrganizationId = organization.Id, + }; + + await collectionRepository.CreateAsync(collection, + [ + new CollectionAccessSelection { Id = group1.Id, Manage = true, HidePasswords = true, ReadOnly = false, }, + new CollectionAccessSelection { Id = group2.Id, Manage = false, HidePasswords = false, ReadOnly = true, }, + ], + [ + new CollectionAccessSelection { Id = orgUser1.Id, Manage = true, HidePasswords = false, ReadOnly = true }, + new CollectionAccessSelection { Id = orgUser2.Id, Manage = false, HidePasswords = true, ReadOnly = false }, + ] + ); + + // Act + collection.Name = "Updated Collection Name"; + + await collectionRepository.ReplaceAsync(collection, null, null); + + // Assert + var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + + Assert.NotNull(actualCollection); + Assert.Equal("Updated Collection Name", actualCollection.Name); + + var groups = actualAccess.Groups.ToArray(); + Assert.Equal(2, groups.Length); + Assert.Single(groups, g => g.Id == group1.Id && g.Manage && g.HidePasswords && !g.ReadOnly); + Assert.Single(groups, g => g.Id == group2.Id && !g.Manage && !g.HidePasswords && g.ReadOnly); + + var users = actualAccess.Users.ToArray(); + + Assert.Equal(2, users.Length); + Assert.Single(users, u => u.Id == orgUser1.Id && u.Manage && !u.HidePasswords && u.ReadOnly); + Assert.Single(users, u => u.Id == orgUser2.Id && !u.Manage && u.HidePasswords && !u.ReadOnly); + + // Clean up data + await userRepository.DeleteAsync(user1); + await userRepository.DeleteAsync(user2); + await organizationRepository.DeleteAsync(organization); + } } diff --git a/util/Migrator/DbScripts/2025-12-10_00_AddGroupAndUserCollectionUpdates.sql b/util/Migrator/DbScripts/2025-12-10_00_AddGroupAndUserCollectionUpdates.sql new file mode 100644 index 0000000000..162be5a7b2 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-10_00_AddGroupAndUserCollectionUpdates.sql @@ -0,0 +1,151 @@ +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithUsers] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Users + -- Delete users that are no longer in source + DELETE + cu + FROM + [dbo].[CollectionUser] cu + LEFT JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND u.Id IS NULL; + + -- Update existing users + UPDATE + cu + SET + cu.ReadOnly = u.ReadOnly, + cu.HidePasswords = u.HidePasswords, + cu.Manage = u.Manage + FROM + [dbo].[CollectionUser] cu + INNER JOIN + @Users u ON cu.OrganizationUserId = u.Id + WHERE + cu.CollectionId = @Id + AND ( + cu.ReadOnly != u.ReadOnly + OR cu.HidePasswords != u.HidePasswords + OR cu.Manage != u.Manage + ); + + -- Insert new users + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + u.Id, + u.ReadOnly, + u.HidePasswords, + u.Manage + FROM + @Users u + INNER JOIN + [dbo].[OrganizationUser] ou ON ou.Id = u.Id + LEFT JOIN + [dbo].[CollectionUser] cu ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id + WHERE + ou.OrganizationId = @OrganizationId + AND cu.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroups] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name VARCHAR(MAX), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @DefaultUserCollectionEmail NVARCHAR(256) = NULL, + @Type TINYINT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate, @DefaultUserCollectionEmail, @Type + + -- Groups + -- Delete groups that are no longer in source + DELETE + cg + FROM + [dbo].[CollectionGroup] cg + LEFT JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND g.Id IS NULL; + + -- Update existing groups + UPDATE + cg + SET + cg.ReadOnly = g.ReadOnly, + cg.HidePasswords = g.HidePasswords, + cg.Manage = g.Manage + FROM + [dbo].[CollectionGroup] cg + INNER JOIN + @Groups g ON cg.GroupId = g.Id + WHERE + cg.CollectionId = @Id + AND ( + cg.ReadOnly != g.ReadOnly + OR cg.HidePasswords != g.HidePasswords + OR cg.Manage != g.Manage + ); + + -- Insert new groups + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + @Id, + g.Id, + g.ReadOnly, + g.HidePasswords, + g.Manage + FROM + @Groups g + INNER JOIN + [dbo].[Group] grp ON grp.Id = g.Id + LEFT JOIN + [dbo].[CollectionGroup] cg ON cg.CollectionId = @Id AND cg.GroupId = g.Id + WHERE + grp.OrganizationId = @OrganizationId + AND cg.CollectionId IS NULL; + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId +END +GO From 19ee4a0054e1af84b3e7e632dda513fb2cdad108 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:42:33 -0500 Subject: [PATCH 27/47] [deps] BRE: Update rabbitmq Docker tag to v4.2.0 (#4026) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- dev/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 3554306ddb..c82da051b4 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -99,7 +99,7 @@ services: - idp rabbitmq: - image: rabbitmq:4.1.3-management + image: rabbitmq:4.2.0-management ports: - "5672:5672" - "15672:15672" From b3437b3b305c7b90107813dbfa2d26b3a64fc8f9 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:00:05 -0500 Subject: [PATCH 28/47] Update requirements for RabbitMQ and Azure Service Bus configuration (#6741) --- ...IntegrationsServiceCollectionExtensions.cs | 26 ++++-- ...grationServiceCollectionExtensionsTests.cs | 82 +++++++++++++++---- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs index 5dce52d907..ebeef44484 100644 --- a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -528,17 +528,21 @@ public static class EventIntegrationsServiceCollectionExtensions /// True if all required RabbitMQ settings are present; otherwise, false. /// /// Requires all the following settings to be configured: - /// - EventLogging.RabbitMq.HostName - /// - EventLogging.RabbitMq.Username - /// - EventLogging.RabbitMq.Password - /// - EventLogging.RabbitMq.EventExchangeName + /// + /// EventLogging.RabbitMq.HostName + /// EventLogging.RabbitMq.Username + /// EventLogging.RabbitMq.Password + /// EventLogging.RabbitMq.EventExchangeName + /// EventLogging.RabbitMq.IntegrationExchangeName + /// /// internal static bool IsRabbitMqEnabled(GlobalSettings settings) { return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && - CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName); + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.IntegrationExchangeName); } /// @@ -547,13 +551,17 @@ public static class EventIntegrationsServiceCollectionExtensions /// The global settings containing Azure Service Bus configuration. /// True if all required Azure Service Bus settings are present; otherwise, false. /// - /// Requires both of the following settings to be configured: - /// - EventLogging.AzureServiceBus.ConnectionString - /// - EventLogging.AzureServiceBus.EventTopicName + /// Requires all of the following settings to be configured: + /// + /// EventLogging.AzureServiceBus.ConnectionString + /// EventLogging.AzureServiceBus.EventTopicName + /// EventLogging.AzureServiceBus.IntegrationTopicName + /// /// internal static bool IsAzureServiceBusEnabled(GlobalSettings settings) { return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) && - CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName); + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName) && + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.IntegrationTopicName); } } diff --git a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs index 08fcd23969..0ca2d55c78 100644 --- a/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs +++ b/test/Core.Test/AdminConsole/EventIntegrations/EventIntegrationServiceCollectionExtensionsTests.cs @@ -200,7 +200,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.True(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -214,7 +215,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = null, ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -228,7 +230,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = null, ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -242,21 +245,38 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = null, - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); } [Fact] - public void IsRabbitMqEnabled_MissingExchangeName_ReturnsFalse() + public void IsRabbitMqEnabled_MissingEventExchangeName_ReturnsFalse() { var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = null + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = null, + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); + } + + [Fact] + public void IsRabbitMqEnabled_MissingIntegrationExchangeName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", + ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", + ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = null }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsRabbitMqEnabled(globalSettings)); @@ -268,7 +288,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); Assert.True(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); @@ -280,19 +301,34 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = null, - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); } [Fact] - public void IsAzureServiceBusEnabled_MissingTopicName_ReturnsFalse() + public void IsAzureServiceBusEnabled_MissingEventTopicName_ReturnsFalse() { var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = null + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = null, + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" + }); + + Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); + } + + [Fact] + public void IsAzureServiceBusEnabled_MissingIntegrationTopicName_ReturnsFalse() + { + var globalSettings = CreateGlobalSettings(new Dictionary + { + ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = null }); Assert.False(EventIntegrationsServiceCollectionExtensions.IsAzureServiceBusEnabled(globalSettings)); @@ -601,7 +637,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); // Add prerequisites @@ -624,7 +661,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); // Add prerequisites @@ -650,8 +688,10 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration", ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); // Add prerequisites @@ -694,7 +734,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); services.AddEventWriteServices(globalSettings); @@ -712,7 +753,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); services.AddEventWriteServices(globalSettings); @@ -769,10 +811,12 @@ public class EventIntegrationServiceCollectionExtensionsTests { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration", ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); services.AddEventWriteServices(globalSettings); @@ -789,7 +833,8 @@ public class EventIntegrationServiceCollectionExtensionsTests var globalSettings = CreateGlobalSettings(new Dictionary { ["GlobalSettings:EventLogging:AzureServiceBus:ConnectionString"] = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", - ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events" + ["GlobalSettings:EventLogging:AzureServiceBus:EventTopicName"] = "events", + ["GlobalSettings:EventLogging:AzureServiceBus:IntegrationTopicName"] = "integration" }); // Add prerequisites @@ -826,7 +871,8 @@ public class EventIntegrationServiceCollectionExtensionsTests ["GlobalSettings:EventLogging:RabbitMq:HostName"] = "localhost", ["GlobalSettings:EventLogging:RabbitMq:Username"] = "user", ["GlobalSettings:EventLogging:RabbitMq:Password"] = "pass", - ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange" + ["GlobalSettings:EventLogging:RabbitMq:EventExchangeName"] = "exchange", + ["GlobalSettings:EventLogging:RabbitMq:IntegrationExchangeName"] = "integration" }); // Add prerequisites From 3cb8472fd226989a4f10e9bc6b71ab8000d714a3 Mon Sep 17 00:00:00 2001 From: aj-bw <81774843+aj-bw@users.noreply.github.com> Date: Wed, 17 Dec 2025 19:31:21 +0000 Subject: [PATCH 29/47] adding platform tag to optimze build, avoiding unnecessary emulation (#6745) --- src/Admin/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 648ff1be91..84248639cf 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Node.js build stage # ############################################### -FROM node:20-alpine3.21 AS node-build +FROM --platform=$BUILDPLATFORM node:20-alpine3.21 AS node-build WORKDIR /app COPY src/Admin/package*.json ./ From 8aa8bba9a65051047d36adee81b0c6978f410e13 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:29:06 -0700 Subject: [PATCH 30/47] Add feature flag for windows desktop autotype GA (#6717) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 95ab009722..97f463b1b3 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -183,6 +183,7 @@ public static class FeatureFlagKeys public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string InlineMenuTotp = "inline-menu-totp"; public const string WindowsDesktopAutotype = "windows-desktop-autotype"; + public const string WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga"; /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; From d03277323fb9957c3d8cc5ae2d17f07efc9ad0f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:56:13 -0500 Subject: [PATCH 31/47] [deps]: Update actions/stale action to v10 (#6335) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/stale-bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 83d492645e..c683400a60 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -15,7 +15,7 @@ jobs: pull-requests: write steps: - name: Check - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: stale-issue-label: "needs-reply" stale-pr-label: "needs-changes" From 982957a2beb4b0fd47c2951637375ab263a504bb Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 18 Dec 2025 09:12:16 -0600 Subject: [PATCH 32/47] [PM-21421] Support legacy > current plan transition when resubscribing (#6728) * Refactor RestartSubscriptionCommand to support legacy > modern plan transition * Run dotnet format * Claude feedback * Claude feedback --- .../Commands/RestartSubscriptionCommand.cs | 188 +++-- .../RestartSubscriptionCommandTests.cs | 657 +++++++++++++++--- 2 files changed, 706 insertions(+), 139 deletions(-) diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index 7f7be9d1eb..165b8218a9 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -1,12 +1,13 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; @@ -21,14 +22,14 @@ public interface IRestartSubscriptionCommand } public class RestartSubscriptionCommand( + ILogger logger, IOrganizationRepository organizationRepository, - IProviderRepository providerRepository, + IPricingClient pricingClient, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - IUserRepository userRepository) : IRestartSubscriptionCommand + ISubscriberService subscriberService) : BaseBillingCommand(logger), IRestartSubscriptionCommand { - public async Task> Run( - ISubscriber subscriber) + public Task> Run( + ISubscriber subscriber) => HandleAsync(async () => { var existingSubscription = await subscriberService.GetSubscription(subscriber); @@ -37,56 +38,147 @@ public class RestartSubscriptionCommand( return new BadRequest("Cannot restart a subscription that is not canceled."); } + await RestartSubscriptionAsync(subscriber, existingSubscription); + + return new None(); + }); + + private Task RestartSubscriptionAsync( + ISubscriber subscriber, + Subscription canceledSubscription) => subscriber switch + { + Organization organization => RestartOrganizationSubscriptionAsync(organization, canceledSubscription), + _ => throw new NotSupportedException("Only organization subscriptions can be restarted") + }; + + private async Task RestartOrganizationSubscriptionAsync( + Organization organization, + Subscription canceledSubscription) + { + var plans = await pricingClient.ListPlans(); + + var oldPlan = plans.FirstOrDefault(plan => plan.Type == organization.PlanType); + + if (oldPlan == null) + { + throw new ConflictException("Could not find plan for organization's plan type"); + } + + var newPlan = oldPlan.Disabled + ? plans.FirstOrDefault(plan => + plan.ProductTier == oldPlan.ProductTier && + plan.IsAnnual == oldPlan.IsAnnual && + !plan.Disabled) + : oldPlan; + + if (newPlan == null) + { + throw new ConflictException("Could not find the current, enabled plan for organization's tier and cadence"); + } + + if (newPlan.Type != oldPlan.Type) + { + organization.PlanType = newPlan.Type; + organization.Plan = newPlan.Name; + organization.SelfHost = newPlan.HasSelfHost; + organization.UsePolicies = newPlan.HasPolicies; + organization.UseGroups = newPlan.HasGroups; + organization.UseDirectory = newPlan.HasDirectory; + organization.UseEvents = newPlan.HasEvents; + organization.UseTotp = newPlan.HasTotp; + organization.Use2fa = newPlan.Has2fa; + organization.UseApi = newPlan.HasApi; + organization.UseSso = newPlan.HasSso; + organization.UseOrganizationDomains = newPlan.HasOrganizationDomains; + organization.UseKeyConnector = newPlan.HasKeyConnector; + organization.UseScim = newPlan.HasScim; + organization.UseResetPassword = newPlan.HasResetPassword; + organization.UsersGetPremium = newPlan.UsersGetPremium; + organization.UseCustomPermissions = newPlan.HasCustomPermissions; + } + + var items = new List(); + + // Password Manager + var passwordManagerItem = canceledSubscription.Items.FirstOrDefault(item => + item.Price.Id == (oldPlan.HasNonSeatBasedPasswordManagerPlan() + ? oldPlan.PasswordManager.StripePlanId + : oldPlan.PasswordManager.StripeSeatPlanId)); + + if (passwordManagerItem == null) + { + throw new ConflictException("Organization's subscription does not have a Password Manager subscription item."); + } + + items.Add(new SubscriptionItemOptions + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() ? newPlan.PasswordManager.StripePlanId : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerItem.Quantity + }); + + // Storage + var storageItem = canceledSubscription.Items.FirstOrDefault( + item => item.Price.Id == oldPlan.PasswordManager.StripeStoragePlanId); + + if (storageItem != null) + { + items.Add(new SubscriptionItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storageItem.Quantity + }); + } + + // Secrets Manager & Service Accounts + var secretsManagerItem = oldPlan.SecretsManager != null + ? canceledSubscription.Items.FirstOrDefault(item => + item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId) + : null; + + var serviceAccountsItem = oldPlan.SecretsManager != null + ? canceledSubscription.Items.FirstOrDefault(item => + item.Price.Id == oldPlan.SecretsManager.StripeServiceAccountPlanId) + : null; + + if (newPlan.SecretsManager != null) + { + if (secretsManagerItem != null) + { + items.Add(new SubscriptionItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerItem.Quantity + }); + } + + if (serviceAccountsItem != null) + { + items.Add(new SubscriptionItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccountsItem.Quantity + }); + } + } + var options = new SubscriptionCreateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, CollectionMethod = CollectionMethod.ChargeAutomatically, - Customer = existingSubscription.CustomerId, - Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions - { - Price = subscriptionItem.Price.Id, - Quantity = subscriptionItem.Quantity - }).ToList(), - Metadata = existingSubscription.Metadata, + Customer = canceledSubscription.CustomerId, + Items = items, + Metadata = canceledSubscription.Metadata, OffSession = true, TrialPeriodDays = 0 }; var subscription = await stripeAdapter.CreateSubscriptionAsync(options); - await EnableAsync(subscriber, subscription); - return new None(); - } - private async Task EnableAsync(ISubscriber subscriber, Subscription subscription) - { - switch (subscriber) - { - case Organization organization: - { - organization.GatewaySubscriptionId = subscription.Id; - organization.Enabled = true; - organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); - organization.RevisionDate = DateTime.UtcNow; - await organizationRepository.ReplaceAsync(organization); - break; - } - case Provider provider: - { - provider.GatewaySubscriptionId = subscription.Id; - provider.Enabled = true; - provider.RevisionDate = DateTime.UtcNow; - await providerRepository.ReplaceAsync(provider); - break; - } - case User user: - { - user.GatewaySubscriptionId = subscription.Id; - user.Premium = true; - user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); - user.RevisionDate = DateTime.UtcNow; - await userRepository.ReplaceAsync(user); - break; - } - } + organization.GatewaySubscriptionId = subscription.Id; + organization.Enabled = true; + organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); + organization.RevisionDate = DateTime.UtcNow; + + await organizationRepository.ReplaceAsync(organization); } } diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs index 41f8839eb4..9f34c37b3c 100644 --- a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -1,11 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Test.Billing.Mocks; using NSubstitute; using Stripe; using Xunit; @@ -17,20 +20,19 @@ using static StripeConstants; public class RestartSubscriptionCommandTests { private readonly IOrganizationRepository _organizationRepository = Substitute.For(); - private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly ISubscriberService _subscriberService = Substitute.For(); - private readonly IUserRepository _userRepository = Substitute.For(); private readonly RestartSubscriptionCommand _command; public RestartSubscriptionCommandTests() { _command = new RestartSubscriptionCommand( + Substitute.For>(), _organizationRepository, - _providerRepository, + _pricingClient, _stripeAdapter, - _subscriberService, - _userRepository); + _subscriberService); } [Fact] @@ -63,11 +65,56 @@ public class RestartSubscriptionCommandTests } [Fact] - public async Task Run_Organization_Success_ReturnsNone() + public async Task Run_Provider_ReturnsUnhandledWithNotSupportedException() + { + var provider = new Provider { Id = Guid.NewGuid() }; + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123" + }; + + _subscriberService.GetSubscription(provider).Returns(existingSubscription); + + var result = await _command.Run(provider); + + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.IsType(unhandled.Exception); + } + + [Fact] + public async Task Run_User_ReturnsUnhandledWithNotSupportedException() + { + var user = new User { Id = Guid.NewGuid() }; + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123" + }; + + _subscriberService.GetSubscription(user).Returns(existingSubscription); + + var result = await _command.Run(user); + + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.IsType(unhandled.Exception); + } + + [Fact] + public async Task Run_Organization_MissingPasswordManagerItem_ReturnsUnhandledWithConflictException() { var organizationId = Guid.NewGuid(); - var organization = new Organization { Id = organizationId }; - var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually + }; + + var plan = MockPlans.Get(PlanType.EnterpriseAnnually); var existingSubscription = new Subscription { @@ -77,11 +124,122 @@ public class RestartSubscriptionCommandTests { Data = [ - new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }, - new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 } + new SubscriptionItem { Price = new Price { Id = "some-other-price-id" }, Quantity = 10 } ] }, - Metadata = new Dictionary { ["key"] = "value" } + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + + var result = await _command.Run(organization); + + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.IsType(unhandled.Exception); + Assert.Equal("Organization's subscription does not have a Password Manager subscription item.", unhandled.Exception.Message); + } + + [Fact] + public async Task Run_Organization_PlanNotFound_ReturnsUnhandledWithConflictException() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually + }; + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = "some-price-id" }, Quantity = 10 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + // Return a plan list that doesn't contain the organization's plan type + _pricingClient.ListPlans().Returns([MockPlans.Get(PlanType.TeamsAnnually)]); + + var result = await _command.Run(organization); + + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.IsType(unhandled.Exception); + Assert.Equal("Could not find plan for organization's plan type", unhandled.Exception.Message); + } + + [Fact] + public async Task Run_Organization_DisabledPlanWithNoEnabledReplacement_ReturnsUnhandledWithConflictException() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually2023 + }; + + var oldPlan = new DisabledEnterprisePlan2023(true); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_old", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + // Return only the disabled plan, with no enabled replacement + _pricingClient.ListPlans().Returns([oldPlan]); + + var result = await _command.Run(organization); + + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.IsType(unhandled.Exception); + Assert.Equal("Could not find the current, enabled plan for organization's tier and cadence", unhandled.Exception.Message); + } + + [Fact] + public async Task Run_Organization_WithNonDisabledPlan_PasswordManagerOnly_Success() + { + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually + }; + + var plan = MockPlans.Get(PlanType.EnterpriseAnnually); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 10 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } }; var newSubscription = new Subscription @@ -89,30 +247,26 @@ public class RestartSubscriptionCommandTests Id = "sub_new", Items = new StripeList { - Data = - [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } - ] + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] } }; _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); var result = await _command.Run(organization); Assert.True(result.IsT0); - await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is((SubscriptionCreateOptions options) => + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => options.AutomaticTax.Enabled == true && options.CollectionMethod == CollectionMethod.ChargeAutomatically && options.Customer == "cus_123" && - options.Items.Count == 2 && - options.Items[0].Price == "price_1" && - options.Items[0].Quantity == 1 && - options.Items[1].Price == "price_2" && - options.Items[1].Quantity == 2 && - options.Metadata["key"] == "value" && + options.Items.Count == 1 && + options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.Items[0].Quantity == 10 && + options.Metadata["organizationId"] == organizationId.ToString() && options.OffSession == true && options.TrialPeriodDays == 0)); @@ -120,96 +274,417 @@ public class RestartSubscriptionCommandTests org.Id == organizationId && org.GatewaySubscriptionId == "sub_new" && org.Enabled == true && - org.ExpirationDate == currentPeriodEnd)); + org.ExpirationDate == currentPeriodEnd && + org.PlanType == PlanType.EnterpriseAnnually)); } [Fact] - public async Task Run_Provider_Success_ReturnsNone() + public async Task Run_Organization_WithNonDisabledPlan_WithStorage_Success() { - var providerId = Guid.NewGuid(); - var provider = new Provider { Id = providerId }; - - var existingSubscription = new Subscription - { - Status = SubscriptionStatus.Canceled, - CustomerId = "cus_123", - Items = new StripeList - { - Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] - }, - Metadata = new Dictionary() - }; - - var newSubscription = new Subscription - { - Id = "sub_new", - Items = new StripeList - { - Data = - [ - new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } - ] - } - }; - - _subscriberService.GetSubscription(provider).Returns(existingSubscription); - _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); - - var result = await _command.Run(provider); - - Assert.True(result.IsT0); - - await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); - - await _providerRepository.Received(1).ReplaceAsync(Arg.Is(prov => - prov.Id == providerId && - prov.GatewaySubscriptionId == "sub_new" && - prov.Enabled == true)); - } - - [Fact] - public async Task Run_User_Success_ReturnsNone() - { - var userId = Guid.NewGuid(); - var user = new User { Id = userId }; + var organizationId = Guid.NewGuid(); var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.TeamsAnnually + }; + + var plan = MockPlans.Get(PlanType.TeamsAnnually); var existingSubscription = new Subscription { Status = SubscriptionStatus.Canceled, - CustomerId = "cus_123", - Items = new StripeList - { - Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] - }, - Metadata = new Dictionary() - }; - - var newSubscription = new Subscription - { - Id = "sub_new", + CustomerId = "cus_456", Items = new StripeList { Data = [ - new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 5 }, + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 3 } ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_new_2", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] } }; - _subscriberService.GetSubscription(user).Returns(existingSubscription); + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); - var result = await _command.Run(user); + var result = await _command.Run(organization); Assert.True(result.IsT0); - await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => + options.Items.Count == 2 && + options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.Items[0].Quantity == 5 && + options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId && + options.Items[1].Quantity == 3)); - await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => - u.Id == userId && - u.GatewaySubscriptionId == "sub_new" && - u.Premium == true && - u.PremiumExpirationDate == currentPeriodEnd)); + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_new_2" && + org.Enabled == true)); + } + + [Fact] + public async Task Run_Organization_WithSecretsManager_Success() + { + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseMonthly + }; + + var plan = MockPlans.Get(PlanType.EnterpriseMonthly); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_789", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 15 }, + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 2 }, + new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 10 }, + new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeServiceAccountPlanId }, Quantity = 100 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_new_3", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => + options.Items.Count == 4 && + options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.Items[0].Quantity == 15 && + options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId && + options.Items[1].Quantity == 2 && + options.Items[2].Price == plan.SecretsManager.StripeSeatPlanId && + options.Items[2].Quantity == 10 && + options.Items[3].Price == plan.SecretsManager.StripeServiceAccountPlanId && + options.Items[3].Quantity == 100)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_new_3" && + org.Enabled == true)); + } + + [Fact] + public async Task Run_Organization_WithDisabledPlan_UpgradesToNewPlan_Success() + { + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.EnterpriseAnnually2023 + }; + + var oldPlan = new DisabledEnterprisePlan2023(true); + var newPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_old", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeSeatPlanId }, Quantity = 20 }, + new SubscriptionItem { Price = new Price { Id = oldPlan.PasswordManager.StripeStoragePlanId }, Quantity = 5 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_upgraded", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([oldPlan, newPlan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => + options.Items.Count == 2 && + options.Items[0].Price == newPlan.PasswordManager.StripeSeatPlanId && + options.Items[0].Quantity == 20 && + options.Items[1].Price == newPlan.PasswordManager.StripeStoragePlanId && + options.Items[1].Quantity == 5)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_upgraded" && + org.Enabled == true && + org.PlanType == PlanType.EnterpriseAnnually && + org.Plan == newPlan.Name && + org.SelfHost == newPlan.HasSelfHost && + org.UsePolicies == newPlan.HasPolicies && + org.UseGroups == newPlan.HasGroups && + org.UseDirectory == newPlan.HasDirectory && + org.UseEvents == newPlan.HasEvents && + org.UseTotp == newPlan.HasTotp && + org.Use2fa == newPlan.Has2fa && + org.UseApi == newPlan.HasApi && + org.UseSso == newPlan.HasSso && + org.UseOrganizationDomains == newPlan.HasOrganizationDomains && + org.UseKeyConnector == newPlan.HasKeyConnector && + org.UseScim == newPlan.HasScim && + org.UseResetPassword == newPlan.HasResetPassword && + org.UsersGetPremium == newPlan.UsersGetPremium && + org.UseCustomPermissions == newPlan.HasCustomPermissions)); + } + + [Fact] + public async Task Run_Organization_WithStorageAndSecretManagerButNoServiceAccounts_Success() + { + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.TeamsAnnually + }; + + var plan = MockPlans.Get(PlanType.TeamsAnnually); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_complex", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 12 }, + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeStoragePlanId }, Quantity = 8 }, + new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 6 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_complex", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => + options.Items.Count == 3 && + options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.Items[0].Quantity == 12 && + options.Items[1].Price == plan.PasswordManager.StripeStoragePlanId && + options.Items[1].Quantity == 8 && + options.Items[2].Price == plan.SecretsManager.StripeSeatPlanId && + options.Items[2].Quantity == 6)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_complex" && + org.Enabled == true)); + } + + [Fact] + public async Task Run_Organization_WithSecretsManagerOnly_NoServiceAccounts_Success() + { + var organizationId = Guid.NewGuid(); + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var organization = new Organization + { + Id = organizationId, + PlanType = PlanType.TeamsMonthly + }; + + var plan = MockPlans.Get(PlanType.TeamsMonthly); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_sm", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = plan.PasswordManager.StripeSeatPlanId }, Quantity = 8 }, + new SubscriptionItem { Price = new Price { Id = plan.SecretsManager.StripeSeatPlanId }, Quantity = 5 } + ] + }, + Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() } + }; + + var newSubscription = new Subscription + { + Id = "sub_sm", + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }] + } + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _pricingClient.ListPlans().Returns([plan]); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(options => + options.Items.Count == 2 && + options.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && + options.Items[0].Quantity == 8 && + options.Items[1].Price == plan.SecretsManager.StripeSeatPlanId && + options.Items[1].Quantity == 5)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_sm" && + org.Enabled == true)); + } + + private record DisabledEnterprisePlan2023 : Bit.Core.Models.StaticStore.Plan + { + public DisabledEnterprisePlan2023(bool isAnnual) + { + Type = PlanType.EnterpriseAnnually2023; + ProductTier = ProductTierType.Enterprise; + Name = "Enterprise (Annually) 2023"; + IsAnnual = isAnnual; + NameLocalizationKey = "planNameEnterprise"; + DescriptionLocalizationKey = "planDescEnterprise"; + CanBeUsedByBusiness = true; + TrialPeriodDays = 7; + HasPolicies = true; + HasSelfHost = true; + HasGroups = true; + HasDirectory = true; + HasEvents = true; + HasTotp = true; + Has2fa = true; + HasApi = true; + HasSso = true; + HasOrganizationDomains = true; + HasKeyConnector = true; + HasScim = true; + HasResetPassword = true; + UsersGetPremium = true; + HasCustomPermissions = true; + UpgradeSortOrder = 4; + DisplaySortOrder = 4; + LegacyYear = 2024; + Disabled = true; + + PasswordManager = new PasswordManagerFeatures(isAnnual); + SecretsManager = new SecretsManagerFeatures(isAnnual); + } + + private record SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 200; + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-annually-2023"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2023-annually"; + SeatPrice = 144; + AdditionalPricePerServiceAccount = 12; + } + else + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly-2023"; + StripeServiceAccountPlanId = "secrets-manager-service-account-2023-monthly"; + SeatPrice = 13; + AdditionalPricePerServiceAccount = 1; + } + } + } + + private record PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public PasswordManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BaseStorageGb = 1; + HasAdditionalStorageOption = true; + HasAdditionalSeatsOption = true; + AllowSeatAutoscale = true; + + if (isAnnual) + { + AdditionalStoragePricePerGb = 4; + StripeStoragePlanId = "storage-gb-annually"; + StripeSeatPlanId = "2023-enterprise-org-seat-annually-old"; + SeatPrice = 72; + } + else + { + StripeSeatPlanId = "2023-enterprise-seat-monthly-old"; + StripeStoragePlanId = "storage-gb-monthly"; + SeatPrice = 7; + AdditionalStoragePricePerGb = 0.5M; + } + } + } } } From 25eface1b9ebee3d6b86e51532ccbbc49c79cb08 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:35:56 +0100 Subject: [PATCH 33/47] Remove the feature flag (#6720) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 97f463b1b3..12c144c142 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -192,7 +192,6 @@ public static class FeatureFlagKeys public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog"; public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; - public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; public const string PM26462_Milestone_3 = "pm-26462-milestone-3"; From 2707a965de664f1a88d3f0c3a0325d6592546469 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 18 Dec 2025 19:19:19 +0100 Subject: [PATCH 34/47] Remove additional code review prompt file (#6754) --- .claude/prompts/review-code.md | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .claude/prompts/review-code.md diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md deleted file mode 100644 index 4e5f40b274..0000000000 --- a/.claude/prompts/review-code.md +++ /dev/null @@ -1,25 +0,0 @@ -Please review this pull request with a focus on: - -- Code quality and best practices -- Potential bugs or issues -- Security implications -- Performance considerations - -Note: The PR branch is already checked out in the current working directory. - -Provide a comprehensive review including: - -- Summary of changes since last review -- Critical issues found (be thorough) -- Suggested improvements (be thorough) -- Good practices observed (be concise - list only the most notable items without elaboration) -- Action items for the author -- Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability - -When reviewing subsequent commits: - -- Track status of previously identified issues (fixed/unfixed/reopened) -- Identify NEW problems introduced since last review -- Note if fixes introduced new issues - -IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. From 3511ece8990a73984da6dc855b5bf9d379d95aad Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Thu, 18 Dec 2025 10:20:46 -0800 Subject: [PATCH 35/47] [PM-28746] Add support for Organization_ItemOrganization_Accepted/Declined event types (#6747) --- src/Events/Controllers/CollectController.cs | 24 +++++++++++++- .../Controllers/CollectControllerTests.cs | 33 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index bae1575134..3902522665 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -21,17 +21,21 @@ public class CollectController : Controller private readonly IEventService _eventService; private readonly ICipherRepository _cipherRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; public CollectController( ICurrentContext currentContext, IEventService eventService, ICipherRepository cipherRepository, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository + ) { _currentContext = currentContext; _eventService = eventService; _cipherRepository = cipherRepository; _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; } [HttpPost] @@ -54,6 +58,24 @@ public class CollectController : Controller await _eventService.LogUserEventAsync(_currentContext.UserId.Value, eventModel.Type, eventModel.Date); break; + case EventType.Organization_ItemOrganization_Accepted: + case EventType.Organization_ItemOrganization_Declined: + if (!eventModel.OrganizationId.HasValue || !_currentContext.UserId.HasValue) + { + continue; + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(eventModel.OrganizationId.Value, _currentContext.UserId.Value); + + if (orgUser == null) + { + continue; + } + + await _eventService.LogOrganizationUserEventAsync(orgUser, eventModel.Type, eventModel.Date); + + continue; + // Cipher events case EventType.Cipher_ClientAutofilled: case EventType.Cipher_ClientCopiedHiddenField: diff --git a/test/Events.Test/Controllers/CollectControllerTests.cs b/test/Events.Test/Controllers/CollectControllerTests.cs index 325442d50e..b6fa018623 100644 --- a/test/Events.Test/Controllers/CollectControllerTests.cs +++ b/test/Events.Test/Controllers/CollectControllerTests.cs @@ -1,6 +1,7 @@ using AutoFixture.Xunit2; using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; @@ -9,6 +10,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Events.Controllers; using Bit.Events.Models; +using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; using NSubstitute; @@ -21,6 +23,7 @@ public class CollectControllerTests private readonly IEventService _eventService; private readonly ICipherRepository _cipherRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; public CollectControllerTests() { @@ -28,12 +31,14 @@ public class CollectControllerTests _eventService = Substitute.For(); _cipherRepository = Substitute.For(); _organizationRepository = Substitute.For(); + _organizationUserRepository = Substitute.For(); _sut = new CollectController( _currentContext, _eventService, _cipherRepository, - _organizationRepository + _organizationRepository, + _organizationUserRepository ); } @@ -74,6 +79,32 @@ public class CollectControllerTests await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, eventDate); } + [Theory] + [BitAutoData(EventType.Organization_ItemOrganization_Accepted)] + [BitAutoData(EventType.Organization_ItemOrganization_Declined)] + public async Task Post_Organization_ItemOrganization_LogsOrganizationUserEvent( + EventType type, Guid userId, Guid orgId, OrganizationUser orgUser) + { + _currentContext.UserId.Returns(userId); + orgUser.OrganizationId = orgId; + _organizationUserRepository.GetByOrganizationAsync(orgId, userId).Returns(orgUser); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = type, + OrganizationId = orgId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogOrganizationUserEventAsync(orgUser, type, eventDate); + } + [Theory] [AutoData] public async Task Post_CipherAutofilled_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) From 2b742b0343e9a01e99d674476fb16ea79c506214 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:32:03 +0100 Subject: [PATCH 36/47] [PM-27605] Populate MaxStorageGbIncreased for storage increase from 1GB to 5GB. (#6579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add transition migration to populate MaxStorageGbIncreased This migration populates the MaxStorageGbIncreased column for Users and Organizations by setting it to MaxStorageGb + 4, representing the storage increase from 1GB to 5GB. This migration depends on PM-27603 being deployed first to create the MaxStorageGbIncreased column. Target release: January 6th, 2026 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Using batched updates to reduce lock * Add changes base on ticket adjustment * Added the dependency check * Add temporary index for performance * Resolved the conflict * resolve the syntax error * Update the migration script * Rename the file to updated date * Revert the existing merge file change * revert the change * revert renaming * rename file to updated date * Add the column after renaming * Rename other migration file to set current date --------- Co-authored-by: Claude Co-authored-by: Alex Morask Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../Stored Procedures/Organization_Create.sql | 6 +- .../Stored Procedures/Organization_Update.sql | 3 +- src/Sql/dbo/Stored Procedures/User_Create.sql | 6 +- src/Sql/dbo/Stored Procedures/User_Update.sql | 3 +- ...12-12_00_PopulateMaxStorageGbIncreased.sql | 121 ++++ ...oredProceduresForMaxStorageGbIncreased.sql | 600 ++++++++++++++++++ 6 files changed, 733 insertions(+), 6 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql create mode 100644 util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index decd406280..4fc4681648 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -128,7 +128,8 @@ BEGIN [UseAdminSponsoredFamilies], [SyncSeats], [UseAutomaticUserConfirmation], - [UsePhishingBlocker] + [UsePhishingBlocker], + [MaxStorageGbIncreased] ) VALUES ( @@ -193,6 +194,7 @@ BEGIN @UseAdminSponsoredFamilies, @SyncSeats, @UseAutomaticUserConfirmation, - @UsePhishingBlocker + @UsePhishingBlocker, + @MaxStorageGb ); END diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 9fd1b59460..946cf03e94 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -128,7 +128,8 @@ BEGIN [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies, [SyncSeats] = @SyncSeats, [UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation, - [UsePhishingBlocker] = @UsePhishingBlocker + [UsePhishingBlocker] = @UsePhishingBlocker, + [MaxStorageGbIncreased] = @MaxStorageGb WHERE [Id] = @Id; END diff --git a/src/Sql/dbo/Stored Procedures/User_Create.sql b/src/Sql/dbo/Stored Procedures/User_Create.sql index 2573bf1a0a..cf0c12d1c5 100644 --- a/src/Sql/dbo/Stored Procedures/User_Create.sql +++ b/src/Sql/dbo/Stored Procedures/User_Create.sql @@ -96,7 +96,8 @@ BEGIN [VerifyDevices], [SecurityState], [SecurityVersion], - [SignedPublicKey] + [SignedPublicKey], + [MaxStorageGbIncreased] ) VALUES ( @@ -145,6 +146,7 @@ BEGIN @VerifyDevices, @SecurityState, @SecurityVersion, - @SignedPublicKey + @SignedPublicKey, + @MaxStorageGb ) END diff --git a/src/Sql/dbo/Stored Procedures/User_Update.sql b/src/Sql/dbo/Stored Procedures/User_Update.sql index 5097bc538e..05e0d4b4de 100644 --- a/src/Sql/dbo/Stored Procedures/User_Update.sql +++ b/src/Sql/dbo/Stored Procedures/User_Update.sql @@ -96,7 +96,8 @@ BEGIN [VerifyDevices] = @VerifyDevices, [SecurityState] = @SecurityState, [SecurityVersion] = @SecurityVersion, - [SignedPublicKey] = @SignedPublicKey + [SignedPublicKey] = @SignedPublicKey, + [MaxStorageGbIncreased] = @MaxStorageGb WHERE [Id] = @Id END diff --git a/util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql b/util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql new file mode 100644 index 0000000000..8e3031ccf4 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-12_00_PopulateMaxStorageGbIncreased.sql @@ -0,0 +1,121 @@ + -- ======================================== + -- Dependency Validation + -- ======================================== + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('[dbo].[User]') + AND name = 'MaxStorageGbIncreased' + ) + BEGIN + RAISERROR('MaxStorageGbIncreased column does not exist in User table. PM-27603 must be deployed first.', 16, 1); + RETURN; + END; + + IF NOT EXISTS ( + SELECT 1 FROM sys.columns + WHERE object_id = OBJECT_ID('[dbo].[Organization]') + AND name = 'MaxStorageGbIncreased' + ) + BEGIN + RAISERROR('MaxStorageGbIncreased column does not exist in Organization table. PM-27603 must be deployed first.', 16, 1); + RETURN; + END; + GO + + -- ======================================== + -- User Table Migration + -- ======================================== + + -- Create temporary index for performance + IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.User') + AND name = 'IX_TEMP_User_MaxStorageGb_MaxStorageGbIncreased' + ) + BEGIN + PRINT 'Creating temporary index on User table...'; + CREATE INDEX IX_TEMP_User_MaxStorageGb_MaxStorageGbIncreased + ON [dbo].[User]([MaxStorageGb], [MaxStorageGbIncreased]); + PRINT 'Temporary index created.'; + END + GO + + -- Populate MaxStorageGbIncreased for Users in batches + DECLARE @BatchSize INT = 5000; + DECLARE @RowsAffected INT = 1; + DECLARE @TotalUpdated INT = 0; + + PRINT 'Starting User table update...'; + + WHILE @RowsAffected > 0 + BEGIN + UPDATE TOP (@BatchSize) [dbo].[User] + SET [MaxStorageGbIncreased] = [MaxStorageGb] + 4 + WHERE [MaxStorageGb] IS NOT NULL + AND [MaxStorageGbIncreased] IS NULL; + + SET @RowsAffected = @@ROWCOUNT; + SET @TotalUpdated = @TotalUpdated + @RowsAffected; + + PRINT 'Users updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + + WAITFOR DELAY '00:00:00.100'; -- 100ms delay to reduce contention + END + + PRINT 'User table update complete. Total rows updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + GO + + -- Drop temporary index + DROP INDEX IF EXISTS [dbo].[User].[IX_TEMP_User_MaxStorageGb_MaxStorageGbIncreased]; + PRINT 'Temporary index on User table dropped.'; + GO + + -- ======================================== + -- Organization Table Migration + -- ======================================== + + -- Create temporary index for performance + IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.Organization') + AND name = 'IX_TEMP_Organization_MaxStorageGb_MaxStorageGbIncreased' + ) + BEGIN + PRINT 'Creating temporary index on Organization table...'; + CREATE INDEX IX_TEMP_Organization_MaxStorageGb_MaxStorageGbIncreased + ON [dbo].[Organization]([MaxStorageGb], [MaxStorageGbIncreased]); + PRINT 'Temporary index created.'; + END + GO + + -- Populate MaxStorageGbIncreased for Organizations in batches + DECLARE @BatchSize INT = 5000; + DECLARE @RowsAffected INT = 1; + DECLARE @TotalUpdated INT = 0; + + PRINT 'Starting Organization table update...'; + + WHILE @RowsAffected > 0 + BEGIN + UPDATE TOP (@BatchSize) [dbo].[Organization] + SET [MaxStorageGbIncreased] = [MaxStorageGb] + 4 + WHERE [MaxStorageGb] IS NOT NULL + AND [MaxStorageGbIncreased] IS NULL; + + SET @RowsAffected = @@ROWCOUNT; + SET @TotalUpdated = @TotalUpdated + @RowsAffected; + + PRINT 'Organizations updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + + WAITFOR DELAY '00:00:00.100'; -- 100ms delay to reduce contention + END + + PRINT 'Organization table update complete. Total rows updated: ' + CAST(@TotalUpdated AS VARCHAR(10)); + GO + + -- Drop temporary index + DROP INDEX IF EXISTS [dbo].[Organization].[IX_TEMP_Organization_MaxStorageGb_MaxStorageGbIncreased]; + PRINT 'Temporary index on Organization table dropped.'; + GO \ No newline at end of file diff --git a/util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql b/util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql new file mode 100644 index 0000000000..b679314e40 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-12_00_UpdateStoredProceduresForMaxStorageGbIncreased.sql @@ -0,0 +1,600 @@ +-- Update stored procedures to set MaxStorageGbIncreased column +-- This ensures that going forward, MaxStorageGbIncreased is kept in sync with MaxStorageGb + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT= null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = NULL, + @LimitCollectionDeletion BIT = NULL, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseOrganizationDomains BIT = 0, + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0, + @UseAutomaticUserConfirmation BIT = 0, + @UsePhishingBlocker BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Organization] + ( + [Id], + [Identifier], + [Name], + [BusinessName], + [BusinessAddress1], + [BusinessAddress2], + [BusinessAddress3], + [BusinessCountry], + [BusinessTaxNumber], + [BillingEmail], + [Plan], + [PlanType], + [Seats], + [MaxCollections], + [UsePolicies], + [UseSso], + [UseGroups], + [UseDirectory], + [UseEvents], + [UseTotp], + [Use2fa], + [UseApi], + [UseResetPassword], + [SelfHost], + [UsersGetPremium], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [Enabled], + [LicenseKey], + [PublicKey], + [PrivateKey], + [TwoFactorProviders], + [ExpirationDate], + [CreationDate], + [RevisionDate], + [OwnersNotifiedOfAutoscaling], + [MaxAutoscaleSeats], + [UseKeyConnector], + [UseScim], + [UseCustomPermissions], + [UseSecretsManager], + [Status], + [UsePasswordManager], + [SmSeats], + [SmServiceAccounts], + [MaxAutoscaleSmSeats], + [MaxAutoscaleSmServiceAccounts], + [SecretsManagerBeta], + [LimitCollectionCreation], + [LimitCollectionDeletion], + [AllowAdminAccessToAllCollectionItems], + [UseRiskInsights], + [LimitItemDeletion], + [UseOrganizationDomains], + [UseAdminSponsoredFamilies], + [SyncSeats], + [UseAutomaticUserConfirmation], + [UsePhishingBlocker], + [MaxStorageGbIncreased] + ) + VALUES + ( + @Id, + @Identifier, + @Name, + @BusinessName, + @BusinessAddress1, + @BusinessAddress2, + @BusinessAddress3, + @BusinessCountry, + @BusinessTaxNumber, + @BillingEmail, + @Plan, + @PlanType, + @Seats, + @MaxCollections, + @UsePolicies, + @UseSso, + @UseGroups, + @UseDirectory, + @UseEvents, + @UseTotp, + @Use2fa, + @UseApi, + @UseResetPassword, + @SelfHost, + @UsersGetPremium, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @Enabled, + @LicenseKey, + @PublicKey, + @PrivateKey, + @TwoFactorProviders, + @ExpirationDate, + @CreationDate, + @RevisionDate, + @OwnersNotifiedOfAutoscaling, + @MaxAutoscaleSeats, + @UseKeyConnector, + @UseScim, + @UseCustomPermissions, + @UseSecretsManager, + @Status, + @UsePasswordManager, + @SmSeats, + @SmServiceAccounts, + @MaxAutoscaleSmSeats, + @MaxAutoscaleSmServiceAccounts, + @SecretsManagerBeta, + @LimitCollectionCreation, + @LimitCollectionDeletion, + @AllowAdminAccessToAllCollectionItems, + @UseRiskInsights, + @LimitItemDeletion, + @UseOrganizationDomains, + @UseAdminSponsoredFamilies, + @SyncSeats, + @UseAutomaticUserConfirmation, + @UsePhishingBlocker, + @MaxStorageGb + ); +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Organization_Update] + @Id UNIQUEIDENTIFIER, + @Identifier NVARCHAR(50), + @Name NVARCHAR(50), + @BusinessName NVARCHAR(50), + @BusinessAddress1 NVARCHAR(50), + @BusinessAddress2 NVARCHAR(50), + @BusinessAddress3 NVARCHAR(50), + @BusinessCountry VARCHAR(2), + @BusinessTaxNumber NVARCHAR(30), + @BillingEmail NVARCHAR(256), + @Plan NVARCHAR(50), + @PlanType TINYINT, + @Seats INT, + @MaxCollections SMALLINT, + @UsePolicies BIT, + @UseSso BIT, + @UseGroups BIT, + @UseDirectory BIT, + @UseEvents BIT, + @UseTotp BIT, + @Use2fa BIT, + @UseApi BIT, + @UseResetPassword BIT, + @SelfHost BIT, + @UsersGetPremium BIT, + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @Enabled BIT, + @LicenseKey VARCHAR(100), + @PublicKey VARCHAR(MAX), + @PrivateKey VARCHAR(MAX), + @TwoFactorProviders NVARCHAR(MAX), + @ExpirationDate DATETIME2(7), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @OwnersNotifiedOfAutoscaling DATETIME2(7), + @MaxAutoscaleSeats INT, + @UseKeyConnector BIT = 0, + @UseScim BIT = 0, + @UseCustomPermissions BIT = 0, + @UseSecretsManager BIT = 0, + @Status TINYINT = 0, + @UsePasswordManager BIT = 1, + @SmSeats INT = null, + @SmServiceAccounts INT = null, + @MaxAutoscaleSmSeats INT = null, + @MaxAutoscaleSmServiceAccounts INT = null, + @SecretsManagerBeta BIT = 0, + @LimitCollectionCreation BIT = null, + @LimitCollectionDeletion BIT = null, + @AllowAdminAccessToAllCollectionItems BIT = 0, + @UseRiskInsights BIT = 0, + @LimitItemDeletion BIT = 0, + @UseOrganizationDomains BIT = 0, + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0, + @UseAutomaticUserConfirmation BIT = 0, + @UsePhishingBlocker BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Organization] + SET + [Identifier] = @Identifier, + [Name] = @Name, + [BusinessName] = @BusinessName, + [BusinessAddress1] = @BusinessAddress1, + [BusinessAddress2] = @BusinessAddress2, + [BusinessAddress3] = @BusinessAddress3, + [BusinessCountry] = @BusinessCountry, + [BusinessTaxNumber] = @BusinessTaxNumber, + [BillingEmail] = @BillingEmail, + [Plan] = @Plan, + [PlanType] = @PlanType, + [Seats] = @Seats, + [MaxCollections] = @MaxCollections, + [UsePolicies] = @UsePolicies, + [UseSso] = @UseSso, + [UseGroups] = @UseGroups, + [UseDirectory] = @UseDirectory, + [UseEvents] = @UseEvents, + [UseTotp] = @UseTotp, + [Use2fa] = @Use2fa, + [UseApi] = @UseApi, + [UseResetPassword] = @UseResetPassword, + [SelfHost] = @SelfHost, + [UsersGetPremium] = @UsersGetPremium, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [Enabled] = @Enabled, + [LicenseKey] = @LicenseKey, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [TwoFactorProviders] = @TwoFactorProviders, + [ExpirationDate] = @ExpirationDate, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling, + [MaxAutoscaleSeats] = @MaxAutoscaleSeats, + [UseKeyConnector] = @UseKeyConnector, + [UseScim] = @UseScim, + [UseCustomPermissions] = @UseCustomPermissions, + [UseSecretsManager] = @UseSecretsManager, + [Status] = @Status, + [UsePasswordManager] = @UsePasswordManager, + [SmSeats] = @SmSeats, + [SmServiceAccounts] = @SmServiceAccounts, + [MaxAutoscaleSmSeats] = @MaxAutoscaleSmSeats, + [MaxAutoscaleSmServiceAccounts] = @MaxAutoscaleSmServiceAccounts, + [SecretsManagerBeta] = @SecretsManagerBeta, + [LimitCollectionCreation] = @LimitCollectionCreation, + [LimitCollectionDeletion] = @LimitCollectionDeletion, + [AllowAdminAccessToAllCollectionItems] = @AllowAdminAccessToAllCollectionItems, + [UseRiskInsights] = @UseRiskInsights, + [LimitItemDeletion] = @LimitItemDeletion, + [UseOrganizationDomains] = @UseOrganizationDomains, + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies, + [SyncSeats] = @SyncSeats, + [UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation, + [UsePhishingBlocker] = @UsePhishingBlocker, + [MaxStorageGbIncreased] = @MaxStorageGb + WHERE + [Id] = @Id; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Name NVARCHAR(50), + @Email NVARCHAR(256), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @ApiKey VARCHAR(30), + @ForcePasswordReset BIT = 0, + @UsesKeyConnector BIT = 0, + @FailedLoginCount INT = 0, + @LastFailedLoginDate DATETIME2(7), + @AvatarColor VARCHAR(7) = NULL, + @LastPasswordChangeDate DATETIME2(7) = NULL, + @LastKdfChangeDate DATETIME2(7) = NULL, + @LastKeyRotationDate DATETIME2(7) = NULL, + @LastEmailChangeDate DATETIME2(7) = NULL, + @VerifyDevices BIT = 1, + @SecurityState VARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignedPublicKey VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[User] + ( + [Id], + [Name], + [Email], + [EmailVerified], + [MasterPassword], + [MasterPasswordHint], + [Culture], + [SecurityStamp], + [TwoFactorProviders], + [TwoFactorRecoveryCode], + [EquivalentDomains], + [ExcludedGlobalEquivalentDomains], + [AccountRevisionDate], + [Key], + [PublicKey], + [PrivateKey], + [Premium], + [PremiumExpirationDate], + [RenewalReminderDate], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [LicenseKey], + [Kdf], + [KdfIterations], + [CreationDate], + [RevisionDate], + [ApiKey], + [ForcePasswordReset], + [UsesKeyConnector], + [FailedLoginCount], + [LastFailedLoginDate], + [AvatarColor], + [KdfMemory], + [KdfParallelism], + [LastPasswordChangeDate], + [LastKdfChangeDate], + [LastKeyRotationDate], + [LastEmailChangeDate], + [VerifyDevices], + [SecurityState], + [SecurityVersion], + [SignedPublicKey], + [MaxStorageGbIncreased] + ) + VALUES + ( + @Id, + @Name, + @Email, + @EmailVerified, + @MasterPassword, + @MasterPasswordHint, + @Culture, + @SecurityStamp, + @TwoFactorProviders, + @TwoFactorRecoveryCode, + @EquivalentDomains, + @ExcludedGlobalEquivalentDomains, + @AccountRevisionDate, + @Key, + @PublicKey, + @PrivateKey, + @Premium, + @PremiumExpirationDate, + @RenewalReminderDate, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @LicenseKey, + @Kdf, + @KdfIterations, + @CreationDate, + @RevisionDate, + @ApiKey, + @ForcePasswordReset, + @UsesKeyConnector, + @FailedLoginCount, + @LastFailedLoginDate, + @AvatarColor, + @KdfMemory, + @KdfParallelism, + @LastPasswordChangeDate, + @LastKdfChangeDate, + @LastKeyRotationDate, + @LastEmailChangeDate, + @VerifyDevices, + @SecurityState, + @SecurityVersion, + @SignedPublicKey, + @MaxStorageGb + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_Update] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @Email NVARCHAR(256), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @ApiKey VARCHAR(30), + @ForcePasswordReset BIT = 0, + @UsesKeyConnector BIT = 0, + @FailedLoginCount INT, + @LastFailedLoginDate DATETIME2(7), + @AvatarColor VARCHAR(7), + @LastPasswordChangeDate DATETIME2(7) = NULL, + @LastKdfChangeDate DATETIME2(7) = NULL, + @LastKeyRotationDate DATETIME2(7) = NULL, + @LastEmailChangeDate DATETIME2(7) = NULL, + @VerifyDevices BIT = 1, + @SecurityState VARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignedPublicKey VARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Name] = @Name, + [Email] = @Email, + [EmailVerified] = @EmailVerified, + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Culture] = @Culture, + [SecurityStamp] = @SecurityStamp, + [TwoFactorProviders] = @TwoFactorProviders, + [TwoFactorRecoveryCode] = @TwoFactorRecoveryCode, + [EquivalentDomains] = @EquivalentDomains, + [ExcludedGlobalEquivalentDomains] = @ExcludedGlobalEquivalentDomains, + [AccountRevisionDate] = @AccountRevisionDate, + [Key] = @Key, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [Premium] = @Premium, + [PremiumExpirationDate] = @PremiumExpirationDate, + [RenewalReminderDate] = @RenewalReminderDate, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [LicenseKey] = @LicenseKey, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [ApiKey] = @ApiKey, + [ForcePasswordReset] = @ForcePasswordReset, + [UsesKeyConnector] = @UsesKeyConnector, + [FailedLoginCount] = @FailedLoginCount, + [LastFailedLoginDate] = @LastFailedLoginDate, + [AvatarColor] = @AvatarColor, + [LastPasswordChangeDate] = @LastPasswordChangeDate, + [LastKdfChangeDate] = @LastKdfChangeDate, + [LastKeyRotationDate] = @LastKeyRotationDate, + [LastEmailChangeDate] = @LastEmailChangeDate, + [VerifyDevices] = @VerifyDevices, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [SignedPublicKey] = @SignedPublicKey, + [MaxStorageGbIncreased] = @MaxStorageGb + WHERE + [Id] = @Id +END +GO From a92d7ac129612e25ac17cc5c3a447d8a14c41db0 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:43:03 +0100 Subject: [PATCH 37/47] [PM-27280] Support v2 encryption on key-connector signups (#6712) * account v2 registration for key connector * use new user repository functions * test coverage * integration test coverage * documentation * code review * missing test coverage * fix failing test * failing test * incorrect ticket number * moved back request model to Api, created dedicated data class in Core * sql stored procedure type mismatch, simplification * key connector authorization handler --- .../AccountsKeyManagementController.cs | 34 +- .../SetKeyConnectorKeyRequestModel.cs | 116 ++++-- src/Core/Constants.cs | 1 + .../KeyConnectorAuthorizationHandler.cs | 52 +++ .../Authorization/KeyConnectorOperations.cs | 16 + .../Interfaces/ISetKeyConnectorKeyCommand.cs | 13 + .../Commands/SetKeyConnectorKeyCommand.cs | 60 ++++ ...eyManagementServiceCollectionExtensions.cs | 11 +- .../Models/Data/KeyConnectorKeysData.cs | 12 + src/Core/Repositories/IUserRepository.cs | 2 + src/Core/Services/IUserService.cs | 2 + .../Services/Implementations/UserService.cs | 1 + .../Repositories/UserRepository.cs | 27 ++ .../Repositories/UserRepository.cs | 31 ++ .../User_UpdateKeyConnectorUserKey.sql | 28 ++ .../AccountsKeyManagementControllerTests.cs | 115 +++++- .../AccountsKeyManagementControllerTests.cs | 120 ++++++- .../SetKeyConnectorKeyRequestModelTests.cs | 333 ++++++++++++++++++ .../KeyConnectorAuthorizationHandlerTests.cs | 151 ++++++++ .../SetKeyConnectorKeyCommandTests.cs | 125 +++++++ .../Repositories/UserRepositoryTests.cs | 54 ++- ...2-17_00_User_UpdateKeyConnectorUserKey.sql | 29 ++ 22 files changed, 1283 insertions(+), 50 deletions(-) create mode 100644 src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs create mode 100644 src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs create mode 100644 src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs create mode 100644 src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs create mode 100644 src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs create mode 100644 src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql create mode 100644 test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs create mode 100644 test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs create mode 100644 test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs rename test/Infrastructure.IntegrationTest/{Auth => }/Repositories/UserRepositoryTests.cs (91%) create mode 100644 util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index b944cdd052..a124616e30 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _deviceValidator; private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery; + private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, @@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller emergencyAccessValidator, IRotationValidator, IReadOnlyList> organizationUserValidator, - IRotationValidator, IEnumerable> webAuthnKeyValidator, - IRotationValidator, IEnumerable> deviceValidator) + IRotationValidator, IEnumerable> + webAuthnKeyValidator, + IRotationValidator, IEnumerable> deviceValidator, + ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand) { _userService = userService; _featureService = featureService; @@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller _webauthnKeyValidator = webAuthnKeyValidator; _deviceValidator = deviceValidator; _keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery; + _setKeyConnectorKeyCommand = setKeyConnectorKeyCommand; } [HttpPost("key-management/regenerate-keys")] @@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller throw new UnauthorizedAccessException(); } - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); - if (result.Succeeded) + if (model.IsV2Request()) { - return; + // V2 account registration + await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData()); } - - foreach (var error in result.Errors) + else { - ModelState.AddModelError(string.Empty, error.Description); - } + // V1 account registration + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + if (result.Succeeded) + { + return; + } - throw new BadRequestException(ModelState); + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } [HttpPost("convert-to-key-connector")] diff --git a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 9f52a97383..6cd13fdf83 100644 --- a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -1,36 +1,112 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; namespace Bit.Api.KeyManagement.Models.Requests; -public class SetKeyConnectorKeyRequestModel +public class SetKeyConnectorKeyRequestModel : IValidatableObject { - [Required] - public string Key { get; set; } - [Required] - public KeysRequestModel Keys { get; set; } - [Required] - public KdfType Kdf { get; set; } - [Required] - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - [Required] - public string OrgIdentifier { get; set; } + // TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328 + [Obsolete("Use KeyConnectorKeyWrappedUserKey instead")] + public string? Key { get; set; } + [Obsolete("Use AccountKeys instead")] + public KeysRequestModel? Keys { get; set; } + [Obsolete("Not used anymore")] + public KdfType? Kdf { get; set; } + [Obsolete("Not used anymore")] + public int? KdfIterations { get; set; } + [Obsolete("Not used anymore")] + public int? KdfMemory { get; set; } + [Obsolete("Not used anymore")] + public int? KdfParallelism { get; set; } + + [EncryptedString] + public string? KeyConnectorKeyWrappedUserKey { get; set; } + public AccountKeysRequestModel? AccountKeys { get; set; } + + [Required] + public required string OrgIdentifier { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (IsV2Request()) + { + // V2 registration + yield break; + } + + // V1 registration + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 + if (string.IsNullOrEmpty(Key)) + { + yield return new ValidationResult("Key must be supplied."); + } + + if (Keys == null) + { + yield return new ValidationResult("Keys must be supplied."); + } + + if (Kdf == null) + { + yield return new ValidationResult("Kdf must be supplied."); + } + + if (KdfIterations == null) + { + yield return new ValidationResult("KdfIterations must be supplied."); + } + + if (Kdf == KdfType.Argon2id) + { + if (KdfMemory == null) + { + yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id."); + } + + if (KdfParallelism == null) + { + yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id."); + } + } + } + + public bool IsV2Request() + { + return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null; + } + + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 public User ToUser(User existingUser) { - existingUser.Kdf = Kdf; - existingUser.KdfIterations = KdfIterations; + existingUser.Kdf = Kdf!.Value; + existingUser.KdfIterations = KdfIterations!.Value; existingUser.KdfMemory = KdfMemory; existingUser.KdfParallelism = KdfParallelism; existingUser.Key = Key; - Keys.ToUser(existingUser); + Keys!.ToUser(existingUser); return existingUser; } + + public KeyConnectorKeysData ToKeyConnectorKeysData() + { + // TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328 + if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null) + { + throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied."); + } + + return new KeyConnectorKeysData + { + KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey, + AccountKeys = AccountKeys, + OrgIdentifier = OrgIdentifier + }; + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 12c144c142..94f599a2a6 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -212,6 +212,7 @@ public static class FeatureFlagKeys public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component"; public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; + public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs new file mode 100644 index 0000000000..7937390a8c --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs @@ -0,0 +1,52 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.KeyManagement.Authorization; + +public class KeyConnectorAuthorizationHandler : AuthorizationHandler +{ + private readonly ICurrentContext _currentContext; + + public KeyConnectorAuthorizationHandler(ICurrentContext currentContext) + { + _currentContext = currentContext; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + KeyConnectorOperationsRequirement requirement, + User user) + { + var authorized = requirement switch + { + not null when requirement == KeyConnectorOperations.Use => CanUse(user), + _ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement)) + }; + + if (authorized) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private bool CanUse(User user) + { + // User cannot use Key Connector if they already use it + if (user.UsesKeyConnector) + { + return false; + } + + // User cannot use Key Connector if they are an owner or admin of any organization + if (_currentContext.Organizations.Any(u => + u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)) + { + return false; + } + + return true; + } +} diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs new file mode 100644 index 0000000000..a8d09a6ac7 --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.KeyManagement.Authorization; + +public class KeyConnectorOperationsRequirement : OperationAuthorizationRequirement +{ + public KeyConnectorOperationsRequirement(string name) + { + Name = name; + } +} + +public static class KeyConnectorOperations +{ + public static readonly KeyConnectorOperationsRequirement Use = new(nameof(Use)); +} diff --git a/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..65f6cddeb5 --- /dev/null +++ b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Commands.Interfaces; + +/// +/// Creates the user key and account cryptographic state for a new user registering +/// with Key Connector SSO configuration. +/// +public interface ISetKeyConnectorKeyCommand +{ + Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData); +} diff --git a/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..a96042de30 --- /dev/null +++ b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Authorization; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.KeyManagement.Commands; + +public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand +{ + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + private readonly IEventService _eventService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + + public SetKeyConnectorKeyCommand( + IAuthorizationService authorizationService, + ICurrentContext currentContext, + IEventService eventService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IUserService userService, + IUserRepository userRepository) + { + _authorizationService = authorizationService; + _currentContext = currentContext; + _eventService = eventService; + _acceptOrgUserCommand = acceptOrgUserCommand; + _userService = userService; + _userRepository = userRepository; + } + + public async Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData) + { + var authorizationResult = await _authorizationService.AuthorizeAsync(_currentContext.HttpContext.User, user, + KeyConnectorOperations.Use); + if (!authorizationResult.Succeeded) + { + throw new BadRequestException("Cannot use Key Connector"); + } + + var setKeyConnectorUserKeyTask = + _userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorKeysData.KeyConnectorKeyWrappedUserKey); + + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, + keyConnectorKeysData.AccountKeys.ToAccountKeysData(), [setKeyConnectorUserKeyTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + + await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(keyConnectorKeysData.OrgIdentifier, user, + _userService); + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index abaf9406ba..96f990c299 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -1,9 +1,11 @@ -using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Authorization; +using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Kdf.Implementations; using Bit.Core.KeyManagement.Queries; using Bit.Core.KeyManagement.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.KeyManagement; @@ -12,15 +14,22 @@ public static class KeyManagementServiceCollectionExtensions { public static void AddKeyManagementServices(this IServiceCollection services) { + services.AddKeyManagementAuthorizationHandlers(); services.AddKeyManagementCommands(); services.AddKeyManagementQueries(); services.AddSendPasswordServices(); } + private static void AddKeyManagementAuthorizationHandlers(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddKeyManagementQueries(this IServiceCollection services) diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs new file mode 100644 index 0000000000..5675c6bc96 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs @@ -0,0 +1,12 @@ +using Bit.Core.KeyManagement.Models.Api.Request; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorKeysData +{ + public required string KeyConnectorKeyWrappedUserKey { get; set; } + + public required AccountKeysRequestModel AccountKeys { get; set; } + + public required string OrgIdentifier { get; init; } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 47ddb86f8e..93316d78bd 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -72,6 +72,8 @@ public interface IUserRepository : IRepository UserAccountKeysData accountKeysData, IEnumerable? updateUserDataActions = null); Task DeleteManyAsync(IEnumerable users); + + UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey); } public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index fade63de51..a531883db1 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -33,6 +33,8 @@ public interface IUserService Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key); + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 + [Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")] Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 8db66211b1..4e65e88767 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -621,6 +621,7 @@ public class UserService : UserManager, IUserService return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 public async Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier) { var identityResult = CheckCanUseKeyConnector(user); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 224351f034..571319e4c7 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Core; using Bit.Core.Billing.Premium.Models; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -401,6 +402,32 @@ public class UserRepository : Repository, IUserRepository return result.SingleOrDefault(); } + public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey) + { + return async (connection, transaction) => + { + var timestamp = DateTime.UtcNow; + + await connection!.ExecuteAsync( + "[dbo].[User_UpdateKeyConnectorUserKey]", + new + { + Id = userId, + Key = keyConnectorWrappedUserKey, + // Key Connector does not use KDF, so we set some defaults + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + UsesKeyConnector = true, + RevisionDate = timestamp, + AccountRevisionDate = timestamp + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 9bf093e506..56d64094d0 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -1,5 +1,7 @@ using AutoMapper; +using Bit.Core; using Bit.Core.Billing.Premium.Models; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -479,6 +481,35 @@ public class UserRepository : Repository, IUserR } } + public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey) + { + return async (_, _) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var userEntity = await dbContext.Users.FindAsync(userId); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(userId)); + } + + var timestamp = DateTime.UtcNow; + + userEntity.Key = keyConnectorWrappedUserKey; + // Key Connector does not use KDF, so we set some defaults + userEntity.Kdf = KdfType.Argon2id; + userEntity.KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default; + userEntity.KdfMemory = AuthConstants.ARGON2_MEMORY.Default; + userEntity.KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default; + userEntity.UsesKeyConnector = true; + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + await dbContext.SaveChangesAsync(); + }; + } + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) { var defaultCollections = (from c in dbContext.Collections diff --git a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql new file mode 100644 index 0000000000..7ab20a42af --- /dev/null +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql @@ -0,0 +1,28 @@ +CREATE PROCEDURE [dbo].[User_UpdateKeyConnectorUserKey] + @Id UNIQUEIDENTIFIER, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT, + @KdfParallelism INT, + @UsesKeyConnector BIT, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [UsesKeyConnector] = @UsesKeyConnector, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1c456df106..eddffb6b36 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -19,9 +20,11 @@ using Bit.Core.KeyManagement.Enums; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Repositories; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Vault.Enums; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.KeyManagement.Controllers; @@ -31,6 +34,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => + { + featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) + .Returns(true); + }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _userRepository = _factory.GetService(); @@ -78,8 +85,11 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => + { + featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) + .Returns(false); + }); var localClient = localFactory.CreateClient(); var localEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; var localLoginHelper = new LoginHelper(localFactory, localClient); @@ -285,21 +295,21 @@ public class AccountsKeyManagementControllerTests : IClassFixture sutProvider, SetKeyConnectorKeyRequestModel data) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); @@ -252,10 +255,13 @@ public class AccountsKeyManagementControllerTests [Theory] [BitAutoData] - public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( + public async Task PostSetKeyConnectorKeyAsync_V1_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( SutProvider sutProvider, SetKeyConnectorKeyRequestModel data, User expectedUser) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + expectedUser.PublicKey = null; expectedUser.PrivateKey = null; sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) @@ -278,17 +284,20 @@ public class AccountsKeyManagementControllerTests Assert.Equal(data.KdfIterations, user.KdfIterations); Assert.Equal(data.KdfMemory, user.KdfMemory); Assert.Equal(data.KdfParallelism, user.KdfParallelism); - Assert.Equal(data.Keys.PublicKey, user.PublicKey); - Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + Assert.Equal(data.Keys!.PublicKey, user.PublicKey); + Assert.Equal(data.Keys!.EncryptedPrivateKey, user.PrivateKey); }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); } [Theory] [BitAutoData] - public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeySucceeds_OkResponse( + public async Task PostSetKeyConnectorKeyAsync_V1_SetKeyConnectorKeySucceeds_OkResponse( SutProvider sutProvider, SetKeyConnectorKeyRequestModel data, User expectedUser) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + expectedUser.PublicKey = null; expectedUser.PrivateKey = null; sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) @@ -308,11 +317,108 @@ public class AccountsKeyManagementControllerTests Assert.Equal(data.KdfIterations, user.KdfIterations); Assert.Equal(data.KdfMemory, user.KdfMemory); Assert.Equal(data.KdfParallelism, user.KdfParallelism); - Assert.Equal(data.Keys.PublicKey, user.PublicKey); - Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + Assert.Equal(data.Keys!.PublicKey, user.PublicKey); + Assert.Equal(data.Keys!.EncryptedPrivateKey, user.PrivateKey); }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); } + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_UserNull_Throws( + SutProvider sutProvider) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request)); + + await sutProvider.GetDependency().DidNotReceive() + .SetKeyConnectorKeyForUserAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_Success( + SutProvider sutProvider, + User expectedUser) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + + await sutProvider.Sut.PostSetKeyConnectorKeyAsync(request); + + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser), + Arg.Do(data => + { + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, data.KeyConnectorKeyWrappedUserKey); + Assert.Equal(request.AccountKeys.AccountPublicKey, data.AccountKeys.AccountPublicKey); + Assert.Equal(request.AccountKeys.UserKeyEncryptedAccountPrivateKey, + data.AccountKeys.UserKeyEncryptedAccountPrivateKey); + Assert.Equal(request.OrgIdentifier, data.OrgIdentifier); + })); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_CommandThrows_PropagatesException( + SutProvider sutProvider, + User expectedUser) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .When(x => x.SetKeyConnectorKeyForUserAsync(Arg.Any(), Arg.Any())) + .Do(_ => throw new BadRequestException("Command failed")); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request)); + + Assert.Equal("Command failed", exception.Message); + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser), + Arg.Do(data => + { + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, data.KeyConnectorKeyWrappedUserKey); + Assert.Equal(request.AccountKeys.AccountPublicKey, data.AccountKeys.AccountPublicKey); + Assert.Equal(request.AccountKeys.UserKeyEncryptedAccountPrivateKey, + data.AccountKeys.UserKeyEncryptedAccountPrivateKey); + Assert.Equal(request.OrgIdentifier, data.OrgIdentifier); + })); + } + [Theory] [BitAutoData] public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( diff --git a/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs new file mode 100644 index 0000000000..95ee743d02 --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs @@ -0,0 +1,333 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class SetKeyConnectorKeyRequestModelTests +{ + private const string _wrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + private const string _publicKey = "public-key"; + private const string _privateKey = "private-key"; + private const string _userKey = "user-key"; + private const string _orgIdentifier = "org-identifier"; + + [Fact] + public void Validate_V2Registration_Valid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey, + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Empty(results); + } + + [Fact] + public void Validate_V2Registration_WrappedUserKeyNotEncryptedString_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "not-encrypted-string", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string."); + } + + [Fact] + public void Validate_V1Registration_Valid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Empty(results); + } + + [Fact] + public void Validate_V1Registration_MissingKey_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = null, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "Key must be supplied."); + } + + [Fact] + public void Validate_V1Registration_MissingKeys_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = null, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "Keys must be supplied."); + } + + [Fact] + public void Validate_V1Registration_MissingKdf_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = null, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "Kdf must be supplied."); + } + + [Fact] + public void Validate_V1Registration_MissingKdfIterations_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = null, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "KdfIterations must be supplied."); + } + + [Fact] + public void Validate_V1Registration_Argon2id_MissingKdfMemory_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = null, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "KdfMemory must be supplied when Kdf is Argon2id."); + } + + [Fact] + public void Validate_V1Registration_Argon2id_MissingKdfParallelism_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = null, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "KdfParallelism must be supplied when Kdf is Argon2id."); + } + + [Fact] + public void ToKeyConnectorKeysData_EmptyKeyConnectorKeyWrappedUserKey_ThrowsException() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var exception = Assert.Throws(() => model.ToKeyConnectorKeysData()); + + // Assert + Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message); + } + + [Fact] + public void ToKeyConnectorKeysData_NullKeyConnectorKeyWrappedUserKey_ThrowsException() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = null, + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var exception = Assert.Throws(() => model.ToKeyConnectorKeysData()); + + // Assert + Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message); + } + + [Fact] + public void ToKeyConnectorKeysData_NullAccountKeys_ThrowsException() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey, + AccountKeys = null, + OrgIdentifier = _orgIdentifier + }; + + // Act + var exception = Assert.Throws(() => model.ToKeyConnectorKeysData()); + + // Assert + Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message); + } + + [Fact] + public void ToKeyConnectorKeysData_Valid_Success() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey, + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var data = model.ToKeyConnectorKeysData(); + + // Assert + Assert.Equal(_wrappedUserKey, data.KeyConnectorKeyWrappedUserKey); + Assert.Equal(_publicKey, data.AccountKeys.AccountPublicKey); + Assert.Equal(_privateKey, data.AccountKeys.UserKeyEncryptedAccountPrivateKey); + Assert.Equal(_orgIdentifier, data.OrgIdentifier); + } + + private static List Validate(SetKeyConnectorKeyRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs b/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..fb774a78ac --- /dev/null +++ b/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs @@ -0,0 +1,151 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Authorization; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Authorization; + +[SutProviderCustomize] +public class KeyConnectorAuthorizationHandlerTests +{ + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserCanUseKeyConnector_Success( + User user, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserAlreadyUsesKeyConnector_Fails( + User user, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = true; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserIsOwner_Fails( + User user, + Guid organizationId, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.Owner } + }; + sutProvider.GetDependency().Organizations.Returns(organizations); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserIsAdmin_Fails( + User user, + Guid organizationId, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.Admin } + }; + sutProvider.GetDependency().Organizations.Returns(organizations); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserIsRegularMember_Success( + User user, + Guid organizationId, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.User } + }; + sutProvider.GetDependency().Organizations.Returns(organizations); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UnsupportedRequirement_ThrowsArgumentException( + User user, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var unsupportedRequirement = new KeyConnectorOperationsRequirement("UnsupportedOperation"); + var context = new AuthorizationHandlerContext([unsupportedRequirement], claimsPrincipal, user); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context)); + } +} diff --git a/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs b/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs new file mode 100644 index 0000000000..74f76f368b --- /dev/null +++ b/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs @@ -0,0 +1,125 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Commands; + +[SutProviderCustomize] +public class SetKeyConnectorKeyCommandTests +{ + + [Theory, BitAutoData] + public async Task SetKeyConnectorKeyForUserAsync_Success_SetsAccountKeys( + User user, + KeyConnectorKeysData data, + SutProvider sutProvider) + { + // Set up valid V2 encryption data + if (data.AccountKeys!.SignatureKeyPair != null) + { + data.AccountKeys.SignatureKeyPair.SignatureAlgorithm = "ed25519"; + } + + var expectedAccountKeysData = data.AccountKeys.ToAccountKeysData(); + + // Arrange + user.UsesKeyConnector = false; + var currentContext = sutProvider.GetDependency(); + var httpContext = Substitute.For(); + httpContext.User.Returns(new ClaimsPrincipal()); + currentContext.HttpContext.Returns(httpContext); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), user, Arg.Any>()) + .Returns(AuthorizationResult.Success()); + + var userRepository = sutProvider.GetDependency(); + var mockUpdateUserData = Substitute.For(); + userRepository.SetKeyConnectorUserKey(user.Id, data.KeyConnectorKeyWrappedUserKey!) + .Returns(mockUpdateUserData); + + // Act + await sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data); + + // Assert + + userRepository + .Received(1) + .SetKeyConnectorUserKey(user.Id, data.KeyConnectorKeyWrappedUserKey); + + await userRepository + .Received(1) + .SetV2AccountCryptographicStateAsync( + user.Id, + Arg.Is(data => + data.PublicKeyEncryptionKeyPairData.PublicKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.PublicKey && + data.PublicKeyEncryptionKeyPairData.WrappedPrivateKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey && + data.PublicKeyEncryptionKeyPairData.SignedPublicKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey && + data.SignatureKeyPairData!.SignatureAlgorithm == expectedAccountKeysData.SignatureKeyPairData!.SignatureAlgorithm && + data.SignatureKeyPairData.WrappedSigningKey == expectedAccountKeysData.SignatureKeyPairData.WrappedSigningKey && + data.SignatureKeyPairData.VerifyingKey == expectedAccountKeysData.SignatureKeyPairData.VerifyingKey && + data.SecurityStateData!.SecurityState == expectedAccountKeysData.SecurityStateData!.SecurityState && + data.SecurityStateData.SecurityVersion == expectedAccountKeysData.SecurityStateData.SecurityVersion), + Arg.Is>(actions => + actions.Count() == 1 && actions.First() == mockUpdateUserData)); + + await sutProvider.GetDependency() + .Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + + await sutProvider.GetDependency() + .Received(1) + .AcceptOrgUserByOrgSsoIdAsync(data.OrgIdentifier, user, sutProvider.GetDependency()); + } + + [Theory, BitAutoData] + public async Task SetKeyConnectorKeyForUserAsync_UserCantUseKeyConnector_ThrowsException( + User user, + KeyConnectorKeysData data, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = true; + var currentContext = sutProvider.GetDependency(); + var httpContext = Substitute.For(); + httpContext.User.Returns(new ClaimsPrincipal()); + currentContext.HttpContext.Returns(httpContext); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), user, Arg.Any>()) + .Returns(AuthorizationResult.Failed()); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data)); + + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetKeyConnectorUserKey(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetV2AccountCryptographicStateAsync(Arg.Any(), Arg.Any(), Arg.Any>()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogUserEventAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AcceptOrgUserByOrgSsoIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs similarity index 91% rename from test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs rename to test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs index bbbd6d5cdb..98b2613ecb 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs @@ -1,9 +1,11 @@ -using Bit.Core.AdminConsole.Repositories; +using Bit.Core; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Microsoft.Data.SqlClient; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -500,4 +502,54 @@ public class UserRepositoryTests // Assert Assert.Empty(results); } + + [Theory, DatabaseData] + public async Task SetKeyConnectorUserKey_UpdatesUserKey(IUserRepository userRepository, Database database) + { + var user = await userRepository.CreateTestUserAsync(); + + const string keyConnectorWrappedKey = "key-connector-wrapped-user-key"; + + var setKeyConnectorUserKeyDelegate = userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorWrappedKey); + + await RunUpdateUserDataAsync(setKeyConnectorUserKeyDelegate, database); + + var updatedUser = await userRepository.GetByIdAsync(user.Id); + + Assert.NotNull(updatedUser); + Assert.Equal(keyConnectorWrappedKey, updatedUser.Key); + Assert.True(updatedUser.UsesKeyConnector); + Assert.Equal(KdfType.Argon2id, updatedUser.Kdf); + Assert.Equal(AuthConstants.ARGON2_ITERATIONS.Default, updatedUser.KdfIterations); + Assert.Equal(AuthConstants.ARGON2_MEMORY.Default, updatedUser.KdfMemory); + Assert.Equal(AuthConstants.ARGON2_PARALLELISM.Default, updatedUser.KdfParallelism); + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + + private static async Task RunUpdateUserDataAsync(UpdateUserData task, Database database) + { + if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) + { + await using var connection = new SqlConnection(database.ConnectionString); + connection.Open(); + + await using var transaction = connection.BeginTransaction(); + try + { + await task(connection, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + else + { + await task(); + } + } } diff --git a/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql b/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql new file mode 100644 index 0000000000..545bf830f6 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql @@ -0,0 +1,29 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_UpdateKeyConnectorUserKey] + @Id UNIQUEIDENTIFIER, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT, + @KdfParallelism INT, + @UsesKeyConnector BIT, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[User] +SET + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [UsesKeyConnector] = @UsesKeyConnector, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate +WHERE + [Id] = @Id +END +GO From 1b41a06e32d819c92494a68f183f44f889d25a8b Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Thu, 18 Dec 2025 14:12:56 -0500 Subject: [PATCH 38/47] [PM-29780] Add feature flag for Send email OTP verification (#6742) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 94f599a2a6..e1ccbbd9b8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -239,6 +239,7 @@ public static class FeatureFlagKeys public const string UseChromiumImporter = "pm-23982-chromium-importer"; public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; public const string SendUIRefresh = "pm-28175-send-ui-refresh"; + public const string SendEmailOTP = "pm-19051-send-email-verification"; /* Vault Team */ public const string CipherKeyEncryption = "cipher-key-encryption"; From cc2d69e1fea685d3714299d1f6820e0003575d14 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:05:18 +1000 Subject: [PATCH 39/47] [PM-28487] Move Events and EventsProcessor to DIRT ownership (#6678) * Move Events and EventsProcessor to DIRT ownership * include test projs * sort lines alphabetically within group * fix order --------- Co-authored-by: Graham Walker --- .github/CODEOWNERS | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 597085d97d..f0c85d98c1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -53,6 +53,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev # Dirt (Data Insights & Reporting) team **/Dirt @bitwarden/team-data-insights-and-reporting-dev +src/Events @bitwarden/team-data-insights-and-reporting-dev +src/EventsProcessor @bitwarden/team-data-insights-and-reporting-dev +test/Events.IntegrationTest @bitwarden/team-data-insights-and-reporting-dev +test/Events.Test @bitwarden/team-data-insights-and-reporting-dev +test/EventsProcessor.Test @bitwarden/team-data-insights-and-reporting-dev # Vault team **/Vault @bitwarden/team-vault-dev @@ -63,8 +68,6 @@ src/Core/IdentityServer @bitwarden/team-auth-dev bitwarden_license/src/Scim @bitwarden/team-admin-console-dev bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev -src/Events @bitwarden/team-admin-console-dev -src/EventsProcessor @bitwarden/team-admin-console-dev # Billing team **/*billing* @bitwarden/team-billing-dev From e6c97bd8505d3a454763e41ebfdb05c92691ad1a Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:10:40 -0500 Subject: [PATCH 40/47] =?UTF-8?q?Revert=20"refactor(IdentityTokenResponse)?= =?UTF-8?q?:=20[Auth/PM-3287]=20Remove=20deprecated=20res=E2=80=A6"=20(#67?= =?UTF-8?q?55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bbe682dae92097fd14ebc78d7f23c80a0eea46ef. --- .../RequestValidators/BaseRequestValidator.cs | 1 + .../CustomTokenRequestValidator.cs | 17 +++++++++++++++++ .../Endpoints/IdentityServerTests.cs | 1 + 3 files changed, 19 insertions(+) diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index b0f3311b2c..0bdf1d89c2 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -671,6 +671,7 @@ public abstract class BaseRequestValidator where T : class customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); customResponse.Add("ForcePasswordReset", user.ForcePasswordReset); + customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); customResponse.Add("Kdf", (byte)user.Kdf); customResponse.Add("KdfIterations", user.KdfIterations); customResponse.Add("KdfMemory", user.KdfMemory); diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 5eee4199b2..38a4813ecd 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -4,6 +4,7 @@ using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -154,7 +155,23 @@ public class CustomTokenRequestValidator : BaseRequestValidator var root = body.RootElement; AssertRefreshTokenExists(root); AssertHelper.AssertJsonProperty(root, "ForcePasswordReset", JsonValueKind.False); + AssertHelper.AssertJsonProperty(root, "ResetMasterPassword", JsonValueKind.False); var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32(); Assert.Equal(0, kdf); var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32(); From 457e293fdc5a56569c5a4ac74ef786563553e0ce Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:35:01 -0800 Subject: [PATCH 41/47] [PM-29017] - improve logic for cipher SaveDetailsAsync validation (#6731) * improve logic for cipher SaveDetailsAsync validation. fix tests * revert change * fix test * remove duplicate semicolon --- .../Services/Implementations/CipherService.cs | 7 +- .../Vault/Services/CipherServiceTests.cs | 73 ++++++++++++++----- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 2085345b16..bb752b471f 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -1029,11 +1029,8 @@ public class CipherService : ICipherService var existingCipherData = DeserializeCipherData(existingCipher); var newCipherData = DeserializeCipherData(cipher); - // "hidden password" users may not add cipher key encryption - if (existingCipher.Key == null && cipher.Key != null) - { - throw new BadRequestException("You do not have permission to add cipher key encryption."); - } + // For hidden-password users, never allow Key to change at all. + cipher.Key = existingCipher.Key; // Keep only non-hidden fileds from the new cipher var nonHiddenFields = newCipherData.Fields?.Where(f => f.Type != FieldType.Hidden) ?? []; // Get hidden fields from the existing cipher diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index c5eecb8f34..fc84651951 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1215,12 +1215,12 @@ public class CipherServiceTests private static SaveDetailsAsyncDependencies GetSaveDetailsAsyncDependencies( SutProvider sutProvider, string newPassword, - bool viewPassword, - bool editPermission, + bool permission, string? key = null, string? totp = null, CipherLoginFido2CredentialData[]? passkeys = null, - CipherFieldData[]? fields = null + CipherFieldData[]? fields = null, + string? existingKey = "OriginalKey" ) { var cipherDetails = new CipherDetails @@ -1233,13 +1233,22 @@ public class CipherServiceTests Key = key, }; - var newLoginData = new CipherLoginData { Username = "user", Password = newPassword, Totp = totp, Fido2Credentials = passkeys, Fields = fields }; + var newLoginData = new CipherLoginData + { + Username = "user", + Password = newPassword, + Totp = totp, + Fido2Credentials = passkeys, + Fields = fields + }; + cipherDetails.Data = JsonSerializer.Serialize(newLoginData); var existingCipher = new Cipher { Id = cipherDetails.Id, Type = CipherType.Login, + Key = existingKey, Data = JsonSerializer.Serialize( new CipherLoginData { @@ -1261,7 +1270,14 @@ public class CipherServiceTests var permissions = new Dictionary { - { cipherDetails.Id, new OrganizationCipherPermission { ViewPassword = viewPassword, Edit = editPermission } } + { + cipherDetails.Id, + new OrganizationCipherPermission + { + ViewPassword = permission, + Edit = permission + } + } }; sutProvider.GetDependency() @@ -1278,7 +1294,7 @@ public class CipherServiceTests [Theory, BitAutoData] public async Task SaveDetailsAsync_PasswordNotChangedWithoutViewPasswordPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: true); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1294,7 +1310,7 @@ public class CipherServiceTests [Theory, BitAutoData] public async Task SaveDetailsAsync_PasswordNotChangedWithoutEditPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1310,7 +1326,7 @@ public class CipherServiceTests [Theory, BitAutoData] public async Task SaveDetailsAsync_PasswordChangedWithPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1326,7 +1342,11 @@ public class CipherServiceTests [Theory, BitAutoData] public async Task SaveDetailsAsync_CipherKeyChangedWithPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, "NewKey"); + var deps = GetSaveDetailsAsyncDependencies( + sutProvider, + newPassword: "NewPassword", + permission: true, + key: "NewKey"); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1336,27 +1356,40 @@ public class CipherServiceTests true); Assert.Equal("NewKey", deps.CipherDetails.Key); + + await sutProvider.GetDependency() + .Received() + .ReplaceAsync(Arg.Is(c => c.Id == deps.CipherDetails.Id && c.Key == "NewKey")); } [Theory, BitAutoData] - public async Task SaveDetailsAsync_CipherKeyChangedWithoutPermission(string _, SutProvider sutProvider) + public async Task SaveDetailsAsync_CipherKeyNotChangedWithoutPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, "NewKey"); + var deps = GetSaveDetailsAsyncDependencies( + sutProvider, + newPassword: "NewPassword", + permission: false, + key: "NewKey" + ); - var exception = await Assert.ThrowsAsync(() => deps.SutProvider.Sut.SaveDetailsAsync( + await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, deps.CipherDetails.UserId.Value, deps.CipherDetails.RevisionDate, null, - true)); + true); - Assert.Contains("do not have permission", exception.Message); + Assert.Equal("OriginalKey", deps.CipherDetails.Key); + + await sutProvider.GetDependency() + .Received() + .ReplaceAsync(Arg.Is(c => c.Id == deps.CipherDetails.Id && c.Key == "OriginalKey")); } [Theory, BitAutoData] public async Task SaveDetailsAsync_TotpChangedWithoutPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, totp: "NewTotp"); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false, totp: "NewTotp"); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1372,7 +1405,7 @@ public class CipherServiceTests [Theory, BitAutoData] public async Task SaveDetailsAsync_TotpChangedWithPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, totp: "NewTotp"); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, totp: "NewTotp"); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1397,7 +1430,7 @@ public class CipherServiceTests } }; - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: false, passkeys: passkeys); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false, passkeys: passkeys); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1422,7 +1455,7 @@ public class CipherServiceTests } }; - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, passkeys: passkeys); + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, passkeys: passkeys); await deps.SutProvider.Sut.SaveDetailsAsync( deps.CipherDetails, @@ -1439,7 +1472,7 @@ public class CipherServiceTests [BitAutoData] public async Task SaveDetailsAsync_HiddenFieldsChangedWithoutPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: false, editPermission: false, fields: + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: false, fields: [ new CipherFieldData { @@ -1464,7 +1497,7 @@ public class CipherServiceTests [BitAutoData] public async Task SaveDetailsAsync_HiddenFieldsChangedWithPermission(string _, SutProvider sutProvider) { - var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", viewPassword: true, editPermission: true, fields: + var deps = GetSaveDetailsAsyncDependencies(sutProvider, "NewPassword", permission: true, fields: [ new CipherFieldData { From bc800a788e9fb4e67a05af112ab00c4b02a8d5ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:06:33 -0500 Subject: [PATCH 42/47] [deps]: Update actions/checkout action to v6 (#6706) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/_move_edd_db_scripts.yml | 4 ++-- .github/workflows/build.yml | 8 ++++---- .github/workflows/cleanup-rc-branch.yml | 2 +- .github/workflows/code-references.yml | 2 +- .github/workflows/load-test.yml | 2 +- .github/workflows/protect-files.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/repository-management.yml | 4 ++-- .github/workflows/test-database.yml | 6 +++--- .github/workflows/test.yml | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/_move_edd_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml index 7e97fa2a07..742e7b897e 100644 --- a/.github/workflows/_move_edd_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -38,7 +38,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Check out branch - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} persist-credentials: false @@ -68,7 +68,7 @@ jobs: if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }} steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b457b9d56..1afaab0882 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -102,7 +102,7 @@ jobs: echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -289,7 +289,7 @@ jobs: actions: read steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -416,7 +416,7 @@ jobs: - win-x64 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 63079826c7..ae482ef4e6 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -31,7 +31,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Checkout main - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: main token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 35e6cfdd40..98f5288ec8 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index cdb53109f5..dd3cef9d83 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -87,7 +87,7 @@ jobs: datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479 - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index a939be6fdb..4b137eb221 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -31,7 +31,7 @@ jobs: label: "DB-migrations-changed" steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 2 persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2272387d84..6f00d4f85f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -106,7 +106,7 @@ jobs: echo "Github Release Option: $RELEASE_OPTION" - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75b4df4e5c..887f78f5df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: fi - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 74823c34b5..a0f7ea73b1 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -91,7 +91,7 @@ jobs: permission-contents: write - name: Check out branch - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: main token: ${{ steps.app-token.outputs.token }} @@ -215,7 +215,7 @@ jobs: permission-contents: write - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index b0d0c076a1..5ce13b25c6 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -44,7 +44,7 @@ jobs: checks: write steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -178,7 +178,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -269,7 +269,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36ab8785d5..72dd17d7d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false From 69d72c2ad3a209b06bce301897fe124fe0c0794a Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:32:51 +1000 Subject: [PATCH 43/47] [PM-28485] Move organization events domain to DIRT code ownership (#6685) --- .../{AdminConsole => Dirt}/Controllers/EventsController.cs | 3 ++- .../Models/Response/EventResponseModel.cs | 2 +- .../Public/Controllers/EventsController.cs | 7 +++---- .../Public/Models}/EventFilterRequestModel.cs | 2 +- .../Response => Dirt/Public/Models}/EventResponseModel.cs | 3 ++- .../Controllers/SecretsManagerEventsController.cs | 1 + src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs | 4 ++-- src/Core/{AdminConsole => Dirt}/Entities/Event.cs | 0 src/Core/{AdminConsole => Dirt}/Enums/EventSystemUser.cs | 0 src/Core/{AdminConsole => Dirt}/Enums/EventType.cs | 0 .../{AdminConsole => Dirt}/Models/Data/EventMessage.cs | 0 .../{AdminConsole => Dirt}/Models/Data/EventTableEntity.cs | 0 src/Core/{AdminConsole => Dirt}/Models/Data/IEvent.cs | 0 .../Repositories/IEventRepository.cs | 0 .../Repositories/TableStorage/EventRepository.cs | 0 .../{AdminConsole => Dirt}/Services/IEventWriteService.cs | 0 .../Implementations/AzureQueueEventWriteService.cs | 0 .../Implementations}/EventIntegrationEventWriteService.cs | 0 .../Services/Implementations/EventService.cs | 0 .../Implementations/RepositoryEventWriteService.cs | 0 .../Services/NoopImplementations/NoopEventService.cs | 0 .../Services/NoopImplementations/NoopEventWriteService.cs | 0 .../{AdminConsole => Dirt}/Repositories/EventRepository.cs | 0 .../Configurations/EventEntityTypeConfiguration.cs | 0 .../{AdminConsole => Dirt}/Models/Event.cs | 0 .../{AdminConsole => Dirt}/Repositories/EventRepository.cs | 0 .../Repositories/Queries/EventReadPageByCipherIdQuery.cs | 0 .../EventReadPageByOrganizationIdActingUserIdQuery.cs | 0 .../Queries/EventReadPageByOrganizationIdQuery.cs | 0 .../EventReadPageByOrganizationIdServiceAccountIdQuery.cs | 0 .../Repositories/Queries/EventReadPageByProjectIdQuery.cs | 0 .../Queries/EventReadPageByProviderIdActingUserIdQuery.cs | 0 .../Repositories/Queries/EventReadPageByProviderIdQuery.cs | 0 .../Repositories/Queries/EventReadPageBySecretIdQuery.cs | 0 .../Queries/EventReadPageByServiceAccountIdQuery.cs | 0 .../Repositories/Queries/EventReadPageByUserIdQuery.cs | 0 src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_Create.sql | 0 .../dbo/{ => Dirt}/Stored Procedures/Event_ReadById.sql | 0 .../Stored Procedures/Event_ReadPageByCipherId.sql | 0 .../Stored Procedures/Event_ReadPageByOrganizationId.sql | 0 .../Event_ReadPageByOrganizationIdActingUserId.sql | 0 .../Stored Procedures/Event_ReadPageByProviderId.sql | 0 .../Event_ReadPageByProviderIdActingUserId.sql | 0 .../Stored Procedures/Event_ReadPageByUserId.sql | 0 src/Sql/dbo/{ => Dirt}/Tables/Event.sql | 0 src/Sql/dbo/{ => Dirt}/Views/EventView.sql | 0 .../DiagnosticTools/EventDiagnosticLoggerTests.cs | 6 +++--- .../Services/AzureQueueEventWriteServiceTests.cs | 0 .../{AdminConsole => Dirt}/Services/EventServiceTests.cs | 0 .../Services/RepositoryEventWriteServiceTests.cs | 0 .../{AdminConsole => Dirt}/Autofixture/EventFixtures.cs | 0 .../Repositories/EqualityComparers/EventCompare.cs | 0 52 files changed, 15 insertions(+), 13 deletions(-) rename src/Api/{AdminConsole => Dirt}/Controllers/EventsController.cs (99%) rename src/Api/{AdminConsole => Dirt}/Models/Response/EventResponseModel.cs (98%) rename src/Api/{AdminConsole => Dirt}/Public/Controllers/EventsController.cs (98%) rename src/Api/{AdminConsole/Public/Models/Request => Dirt/Public/Models}/EventFilterRequestModel.cs (97%) rename src/Api/{AdminConsole/Public/Models/Response => Dirt/Public/Models}/EventResponseModel.cs (98%) rename src/Core/{AdminConsole => Dirt}/Entities/Event.cs (100%) rename src/Core/{AdminConsole => Dirt}/Enums/EventSystemUser.cs (100%) rename src/Core/{AdminConsole => Dirt}/Enums/EventType.cs (100%) rename src/Core/{AdminConsole => Dirt}/Models/Data/EventMessage.cs (100%) rename src/Core/{AdminConsole => Dirt}/Models/Data/EventTableEntity.cs (100%) rename src/Core/{AdminConsole => Dirt}/Models/Data/IEvent.cs (100%) rename src/Core/{AdminConsole => Dirt}/Repositories/IEventRepository.cs (100%) rename src/Core/{AdminConsole => Dirt}/Repositories/TableStorage/EventRepository.cs (100%) rename src/Core/{AdminConsole => Dirt}/Services/IEventWriteService.cs (100%) rename src/Core/{AdminConsole => Dirt}/Services/Implementations/AzureQueueEventWriteService.cs (100%) rename src/Core/{AdminConsole/Services/Implementations/EventIntegrations => Dirt/Services/Implementations}/EventIntegrationEventWriteService.cs (100%) rename src/Core/{AdminConsole => Dirt}/Services/Implementations/EventService.cs (100%) rename src/Core/{ => Dirt}/Services/Implementations/RepositoryEventWriteService.cs (100%) rename src/Core/{AdminConsole => Dirt}/Services/NoopImplementations/NoopEventService.cs (100%) rename src/Core/{AdminConsole => Dirt}/Services/NoopImplementations/NoopEventWriteService.cs (100%) rename src/Infrastructure.Dapper/{AdminConsole => Dirt}/Repositories/EventRepository.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Configurations/EventEntityTypeConfiguration.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Models/Event.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/EventRepository.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByCipherIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByProjectIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByProviderIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageBySecretIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs (100%) rename src/Infrastructure.EntityFramework/{AdminConsole => Dirt}/Repositories/Queries/EventReadPageByUserIdQuery.cs (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_Create.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadById.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadPageByCipherId.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadPageByOrganizationId.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadPageByProviderId.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql (100%) rename src/Sql/dbo/{ => Dirt}/Stored Procedures/Event_ReadPageByUserId.sql (100%) rename src/Sql/dbo/{ => Dirt}/Tables/Event.sql (100%) rename src/Sql/dbo/{ => Dirt}/Views/EventView.sql (100%) rename test/Core.Test/{AdminConsole => Dirt}/Services/AzureQueueEventWriteServiceTests.cs (100%) rename test/Core.Test/{AdminConsole => Dirt}/Services/EventServiceTests.cs (100%) rename test/Core.Test/{AdminConsole => Dirt}/Services/RepositoryEventWriteServiceTests.cs (100%) rename test/Infrastructure.EFIntegration.Test/{AdminConsole => Dirt}/Autofixture/EventFixtures.cs (100%) rename test/Infrastructure.EFIntegration.Test/{AdminConsole => Dirt}/Repositories/EqualityComparers/EventCompare.cs (100%) diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/Dirt/Controllers/EventsController.cs similarity index 99% rename from src/Api/AdminConsole/Controllers/EventsController.cs rename to src/Api/Dirt/Controllers/EventsController.cs index 7e058a7870..1ac83c1316 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/Dirt/Controllers/EventsController.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Api.Dirt.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Api.Utilities.DiagnosticTools; @@ -17,7 +18,7 @@ using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Dirt.Controllers; [Route("events")] [Authorize("Application")] diff --git a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs b/src/Api/Dirt/Models/Response/EventResponseModel.cs similarity index 98% rename from src/Api/AdminConsole/Models/Response/EventResponseModel.cs rename to src/Api/Dirt/Models/Response/EventResponseModel.cs index c259bc3bc4..bfcc50c84e 100644 --- a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs +++ b/src/Api/Dirt/Models/Response/EventResponseModel.cs @@ -2,7 +2,7 @@ using Bit.Core.Models.Api; using Bit.Core.Models.Data; -namespace Bit.Api.Models.Response; +namespace Bit.Api.Dirt.Models.Response; public class EventResponseModel : ResponseModel { diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/Dirt/Public/Controllers/EventsController.cs similarity index 98% rename from src/Api/AdminConsole/Public/Controllers/EventsController.cs rename to src/Api/Dirt/Public/Controllers/EventsController.cs index b92e576ef9..8c76137489 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/Dirt/Public/Controllers/EventsController.cs @@ -1,6 +1,5 @@ - -using System.Net; -using Bit.Api.Models.Public.Request; +using System.Net; +using Bit.Api.Dirt.Public.Models; using Bit.Api.Models.Public.Response; using Bit.Api.Utilities.DiagnosticTools; using Bit.Core.Context; @@ -12,7 +11,7 @@ using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Public.Controllers; +namespace Bit.Api.Dirt.Public.Controllers; [Route("public/events")] [Authorize("Organization")] diff --git a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs similarity index 97% rename from src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs rename to src/Api/Dirt/Public/Models/EventFilterRequestModel.cs index a007349f26..20984c2cb0 100644 --- a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs +++ b/src/Api/Dirt/Public/Models/EventFilterRequestModel.cs @@ -3,7 +3,7 @@ using Bit.Core.Exceptions; -namespace Bit.Api.Models.Public.Request; +namespace Bit.Api.Dirt.Public.Models; public class EventFilterRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs b/src/Api/Dirt/Public/Models/EventResponseModel.cs similarity index 98% rename from src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs rename to src/Api/Dirt/Public/Models/EventResponseModel.cs index 3e1de2747a..77c0b5a275 100644 --- a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs +++ b/src/Api/Dirt/Public/Models/EventResponseModel.cs @@ -1,8 +1,9 @@ using System.ComponentModel.DataAnnotations; +using Bit.Api.Models.Public.Response; using Bit.Core.Enums; using Bit.Core.Models.Data; -namespace Bit.Api.Models.Public.Response; +namespace Bit.Api.Dirt.Public.Models; /// /// An event log. diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs index af162fe399..0f467a4c78 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Api.Dirt.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.Exceptions; diff --git a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs index 9f6a8d2639..af34931181 100644 --- a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs +++ b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs @@ -1,4 +1,4 @@ -using Bit.Api.Models.Public.Request; +using Bit.Api.Dirt.Public.Models; using Bit.Api.Models.Public.Response; using Bit.Core; using Bit.Core.Services; @@ -49,7 +49,7 @@ public static class EventDiagnosticLogger this ILogger logger, IFeatureService featureService, Guid organizationId, - IEnumerable data, + IEnumerable data, string? continuationToken, DateTime? queryStart = null, DateTime? queryEnd = null) diff --git a/src/Core/AdminConsole/Entities/Event.cs b/src/Core/Dirt/Entities/Event.cs similarity index 100% rename from src/Core/AdminConsole/Entities/Event.cs rename to src/Core/Dirt/Entities/Event.cs diff --git a/src/Core/AdminConsole/Enums/EventSystemUser.cs b/src/Core/Dirt/Enums/EventSystemUser.cs similarity index 100% rename from src/Core/AdminConsole/Enums/EventSystemUser.cs rename to src/Core/Dirt/Enums/EventSystemUser.cs diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/Dirt/Enums/EventType.cs similarity index 100% rename from src/Core/AdminConsole/Enums/EventType.cs rename to src/Core/Dirt/Enums/EventType.cs diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/Dirt/Models/Data/EventMessage.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/EventMessage.cs rename to src/Core/Dirt/Models/Data/EventMessage.cs diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/Dirt/Models/Data/EventTableEntity.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/EventTableEntity.cs rename to src/Core/Dirt/Models/Data/EventTableEntity.cs diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/Dirt/Models/Data/IEvent.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/IEvent.cs rename to src/Core/Dirt/Models/Data/IEvent.cs diff --git a/src/Core/AdminConsole/Repositories/IEventRepository.cs b/src/Core/Dirt/Repositories/IEventRepository.cs similarity index 100% rename from src/Core/AdminConsole/Repositories/IEventRepository.cs rename to src/Core/Dirt/Repositories/IEventRepository.cs diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/Dirt/Repositories/TableStorage/EventRepository.cs similarity index 100% rename from src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs rename to src/Core/Dirt/Repositories/TableStorage/EventRepository.cs diff --git a/src/Core/AdminConsole/Services/IEventWriteService.cs b/src/Core/Dirt/Services/IEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/IEventWriteService.cs rename to src/Core/Dirt/Services/IEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs b/src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/AzureQueueEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/AzureQueueEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/EventIntegrationEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/Dirt/Services/Implementations/EventService.cs similarity index 100% rename from src/Core/AdminConsole/Services/Implementations/EventService.cs rename to src/Core/Dirt/Services/Implementations/EventService.cs diff --git a/src/Core/Services/Implementations/RepositoryEventWriteService.cs b/src/Core/Dirt/Services/Implementations/RepositoryEventWriteService.cs similarity index 100% rename from src/Core/Services/Implementations/RepositoryEventWriteService.cs rename to src/Core/Dirt/Services/Implementations/RepositoryEventWriteService.cs diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs similarity index 100% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopEventService.cs diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventWriteService.cs b/src/Core/Dirt/Services/NoopImplementations/NoopEventWriteService.cs similarity index 100% rename from src/Core/AdminConsole/Services/NoopImplementations/NoopEventWriteService.cs rename to src/Core/Dirt/Services/NoopImplementations/NoopEventWriteService.cs diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs b/src/Infrastructure.Dapper/Dirt/Repositories/EventRepository.cs similarity index 100% rename from src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs rename to src/Infrastructure.Dapper/Dirt/Repositories/EventRepository.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/Dirt/Configurations/EventEntityTypeConfiguration.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs rename to src/Infrastructure.EntityFramework/Dirt/Configurations/EventEntityTypeConfiguration.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Event.cs b/src/Infrastructure.EntityFramework/Dirt/Models/Event.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Models/Event.cs rename to src/Infrastructure.EntityFramework/Dirt/Models/Event.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/EventRepository.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/EventRepository.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByCipherIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByCipherIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByCipherIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByCipherIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdActingUserIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProjectIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProjectIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdActingUserIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProviderIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByProviderIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageBySecretIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageBySecretIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByUserIdQuery.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByUserIdQuery.cs similarity index 100% rename from src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByUserIdQuery.cs rename to src/Infrastructure.EntityFramework/Dirt/Repositories/Queries/EventReadPageByUserIdQuery.cs diff --git a/src/Sql/dbo/Stored Procedures/Event_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_Create.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_Create.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_Create.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadById.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadById.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadById.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadById.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByCipherId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByCipherId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByCipherId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByCipherId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByOrganizationIdActingUserId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByProviderIdActingUserId.sql diff --git a/src/Sql/dbo/Stored Procedures/Event_ReadPageByUserId.sql b/src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByUserId.sql similarity index 100% rename from src/Sql/dbo/Stored Procedures/Event_ReadPageByUserId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/Event_ReadPageByUserId.sql diff --git a/src/Sql/dbo/Tables/Event.sql b/src/Sql/dbo/Dirt/Tables/Event.sql similarity index 100% rename from src/Sql/dbo/Tables/Event.sql rename to src/Sql/dbo/Dirt/Tables/Event.sql diff --git a/src/Sql/dbo/Views/EventView.sql b/src/Sql/dbo/Dirt/Views/EventView.sql similarity index 100% rename from src/Sql/dbo/Views/EventView.sql rename to src/Sql/dbo/Dirt/Views/EventView.sql diff --git a/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs b/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs index ada75b148b..95fa949bc7 100644 --- a/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs +++ b/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs @@ -1,4 +1,4 @@ -using Bit.Api.Models.Public.Request; +using Bit.Api.Dirt.Public.Models; using Bit.Api.Models.Public.Response; using Bit.Api.Utilities.DiagnosticTools; using Bit.Core; @@ -155,7 +155,7 @@ public class EventDiagnosticLoggerTests var featureService = Substitute.For(); featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true); - Bit.Api.Models.Response.EventResponseModel[] emptyEvents = []; + Api.Dirt.Models.Response.EventResponseModel[] emptyEvents = []; // Act logger.LogAggregateData(featureService, organizationId, emptyEvents, null, null, null); @@ -188,7 +188,7 @@ public class EventDiagnosticLoggerTests var oldestEvent = Substitute.For(); oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-2)); - var events = new List + var events = new List { new (newestEvent), new (middleEvent), diff --git a/test/Core.Test/AdminConsole/Services/AzureQueueEventWriteServiceTests.cs b/test/Core.Test/Dirt/Services/AzureQueueEventWriteServiceTests.cs similarity index 100% rename from test/Core.Test/AdminConsole/Services/AzureQueueEventWriteServiceTests.cs rename to test/Core.Test/Dirt/Services/AzureQueueEventWriteServiceTests.cs diff --git a/test/Core.Test/AdminConsole/Services/EventServiceTests.cs b/test/Core.Test/Dirt/Services/EventServiceTests.cs similarity index 100% rename from test/Core.Test/AdminConsole/Services/EventServiceTests.cs rename to test/Core.Test/Dirt/Services/EventServiceTests.cs diff --git a/test/Core.Test/AdminConsole/Services/RepositoryEventWriteServiceTests.cs b/test/Core.Test/Dirt/Services/RepositoryEventWriteServiceTests.cs similarity index 100% rename from test/Core.Test/AdminConsole/Services/RepositoryEventWriteServiceTests.cs rename to test/Core.Test/Dirt/Services/RepositoryEventWriteServiceTests.cs diff --git a/test/Infrastructure.EFIntegration.Test/AdminConsole/Autofixture/EventFixtures.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Autofixture/EventFixtures.cs similarity index 100% rename from test/Infrastructure.EFIntegration.Test/AdminConsole/Autofixture/EventFixtures.cs rename to test/Infrastructure.EFIntegration.Test/Dirt/Autofixture/EventFixtures.cs diff --git a/test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/EqualityComparers/EventCompare.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/EqualityComparers/EventCompare.cs similarity index 100% rename from test/Infrastructure.EFIntegration.Test/AdminConsole/Repositories/EqualityComparers/EventCompare.cs rename to test/Infrastructure.EFIntegration.Test/Dirt/Repositories/EqualityComparers/EventCompare.cs From eb360ffec194bbaad45286bc5ab02b18ab340807 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 22 Dec 2025 17:28:27 +0100 Subject: [PATCH 44/47] [PM-29930]Fix [Defect] Automatic Sync - Sync License throws error on Self Host (#6770) * Restore the mistakenly remove controller * Fix the lint build error --- .../Billing/Controllers/LicensesController.cs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/Api/Billing/Controllers/LicensesController.cs diff --git a/src/Api/Billing/Controllers/LicensesController.cs b/src/Api/Billing/Controllers/LicensesController.cs new file mode 100644 index 0000000000..29313bd4d8 --- /dev/null +++ b/src/Api/Billing/Controllers/LicensesController.cs @@ -0,0 +1,91 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Models.Api.OrganizationLicenses; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Route("licenses")] +[Authorize("Licensing")] +[SelfHosted(NotSelfHostedOnly = true)] +public class LicensesController : Controller +{ + private readonly IUserRepository _userRepository; + private readonly IUserService _userService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; + private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand; + private readonly ICurrentContext _currentContext; + + public LicensesController( + IUserRepository userRepository, + IUserService userService, + IOrganizationRepository organizationRepository, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, + IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand, + ICurrentContext currentContext) + { + _userRepository = userRepository; + _userService = userService; + _organizationRepository = organizationRepository; + _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; + _validateBillingSyncKeyCommand = validateBillingSyncKeyCommand; + _currentContext = currentContext; + } + + [HttpGet("user/{id}")] + public async Task GetUser(string id, [FromQuery] string key) + { + var user = await _userRepository.GetByIdAsync(new Guid(id)); + if (user == null) + { + return null; + } + else if (!user.LicenseKey.Equals(key)) + { + await Task.Delay(2000); + throw new BadRequestException("Invalid license key."); + } + + var license = await _userService.GenerateLicenseAsync(user, null); + return license; + } + + /// + /// Used by self-hosted installations to get an updated license file + /// + [HttpGet("organization/{id}")] + public async Task OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model) + { + var organization = await _organizationRepository.GetByIdAsync(new Guid(id)); + if (organization == null) + { + throw new NotFoundException("Organization not found."); + } + + if (!organization.LicenseKey.Equals(model.LicenseKey)) + { + await Task.Delay(2000); + throw new BadRequestException("Invalid license key."); + } + + if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey)) + { + throw new BadRequestException("Invalid Billing Sync Key"); + } + + var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); + return license; + } +} From ae3c8317e3937c0ea19c057d3e8277441e317221 Mon Sep 17 00:00:00 2001 From: sneakernuts <671942+sneakernuts@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:09:23 -0700 Subject: [PATCH 45/47] SRE-3582 billing cleanup (#6772) --- src/Billing/BillingSettings.cs | 34 -- .../Controllers/FreshdeskController.cs | 395 ------------------ .../Controllers/FreshsalesController.cs | 248 ----------- .../Models/FreshdeskReplyRequestModel.cs | 9 - src/Billing/Models/FreshdeskWebhookModel.cs | 24 -- .../OnyxAnswerWithCitationRequestModel.cs | 75 ---- src/Billing/Models/OnyxResponseModel.cs | 15 - src/Billing/Startup.cs | 8 - src/Billing/appsettings.Development.json | 5 - src/Billing/appsettings.Production.json | 5 +- src/Billing/appsettings.json | 21 - .../Controllers/FreshdeskControllerTests.cs | 251 ----------- .../Controllers/FreshsalesControllerTests.cs | 82 ---- 13 files changed, 1 insertion(+), 1171 deletions(-) delete mode 100644 src/Billing/Controllers/FreshdeskController.cs delete mode 100644 src/Billing/Controllers/FreshsalesController.cs delete mode 100644 src/Billing/Models/FreshdeskReplyRequestModel.cs delete mode 100644 src/Billing/Models/FreshdeskWebhookModel.cs delete mode 100644 src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs delete mode 100644 src/Billing/Models/OnyxResponseModel.cs delete mode 100644 test/Billing.Test/Controllers/FreshdeskControllerTests.cs delete mode 100644 test/Billing.Test/Controllers/FreshsalesControllerTests.cs diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 64a52ed290..2830f603ac 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -9,10 +9,7 @@ public class BillingSettings public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; } public virtual string AppleWebhookKey { get; set; } - public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); - public virtual string FreshsalesApiKey { get; set; } public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings(); - public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings(); public class PayPalSettings { @@ -21,35 +18,4 @@ public class BillingSettings public virtual string WebhookKey { get; set; } } - public class FreshDeskSettings - { - public virtual string ApiKey { get; set; } - public virtual string WebhookKey { get; set; } - /// - /// Indicates the data center region. Valid values are "US" and "EU" - /// - public virtual string Region { get; set; } - public virtual string UserFieldName { get; set; } - public virtual string OrgFieldName { get; set; } - - public virtual bool RemoveNewlinesInReplies { get; set; } = false; - public virtual string AutoReplyGreeting { get; set; } = string.Empty; - public virtual string AutoReplySalutation { get; set; } = string.Empty; - } - - public class OnyxSettings - { - public virtual string ApiKey { get; set; } - public virtual string BaseUrl { get; set; } - public virtual string Path { get; set; } - public virtual int PersonaId { get; set; } - public virtual bool UseAnswerWithCitationModels { get; set; } = true; - - public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings(); - } - public class SearchSettings - { - public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto" - public virtual bool RealTime { get; set; } = true; - } } diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs deleted file mode 100644 index 38ed05cfdf..0000000000 --- a/src/Billing/Controllers/FreshdeskController.cs +++ /dev/null @@ -1,395 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Net.Http.Headers; -using System.Reflection; -using System.Text; -using System.Text.Json; -using System.Web; -using Bit.Billing.Models; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Markdig; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace Bit.Billing.Controllers; - -[Route("freshdesk")] -public class FreshdeskController : Controller -{ - private readonly BillingSettings _billingSettings; - private readonly IUserRepository _userRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; - private readonly IHttpClientFactory _httpClientFactory; - - public FreshdeskController( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOptions billingSettings, - ILogger logger, - GlobalSettings globalSettings, - IHttpClientFactory httpClientFactory) - { - _billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings)); - _userRepository = userRepository; - _organizationRepository = organizationRepository; - _logger = logger; - _globalSettings = globalSettings; - _httpClientFactory = httpClientFactory; - } - - [HttpPost("webhook")] - public async Task PostWebhook([FromQuery, Required] string key, - [FromBody, Required] FreshdeskWebhookModel model) - { - if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey)) - { - return new BadRequestResult(); - } - - try - { - var ticketId = model.TicketId; - var ticketContactEmail = model.TicketContactEmail; - var ticketTags = model.TicketTags; - if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail)) - { - return new BadRequestResult(); - } - - var updateBody = new Dictionary(); - var note = string.Empty; - note += $"
  • Region: {_billingSettings.FreshDesk.Region}
  • "; - var customFields = new Dictionary(); - var user = await _userRepository.GetByEmailAsync(ticketContactEmail); - if (user == null) - { - note += $"
  • No user found: {ticketContactEmail}
  • "; - await CreateNote(ticketId, note); - } - - if (user != null) - { - var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"; - note += $"
  • User, {user.Email}: {userLink}
  • "; - customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink); - var tags = new HashSet(); - if (user.Premium) - { - tags.Add("Premium"); - } - var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); - - foreach (var org in orgs) - { - // Prevent org names from injecting any additional HTML - var orgName = HttpUtility.HtmlEncode(org.Name); - var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " + - $"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}"; - note += $"
  • Org, {orgNote}
  • "; - if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName)) - { - customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote); - } - else - { - customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}"; - } - - var displayAttribute = GetAttribute(org.PlanType); - var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(planName)) - { - tags.Add(string.Format("Org: {0}", planName)); - } - } - if (tags.Any()) - { - var tagsToUpdate = tags.ToList(); - if (!string.IsNullOrWhiteSpace(ticketTags)) - { - var splitTicketTags = ticketTags.Split(','); - for (var i = 0; i < splitTicketTags.Length; i++) - { - tagsToUpdate.Insert(i, splitTicketTags[i]); - } - } - updateBody.Add("tags", tagsToUpdate); - } - - if (customFields.Any()) - { - updateBody.Add("custom_fields", customFields); - } - var updateRequest = new HttpRequestMessage(HttpMethod.Put, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId)) - { - Content = JsonContent.Create(updateBody), - }; - await CallFreshdeskApiAsync(updateRequest); - await CreateNote(ticketId, note); - } - - return new OkResult(); - } - catch (Exception e) - { - _logger.LogError(e, "Error processing freshdesk webhook."); - return new BadRequestResult(); - } - } - - [HttpPost("webhook-onyx-ai")] - public async Task PostWebhookOnyxAi([FromQuery, Required] string key, - [FromBody, Required] FreshdeskOnyxAiWebhookModel model) - { - // ensure that the key is from Freshdesk - if (!IsValidRequestFromFreshdesk(key)) - { - return new BadRequestResult(); - } - - // if there is no description, then we don't send anything to onyx - if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) - { - return Ok(); - } - - // Get response from Onyx AI - var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model); - - // the CallOnyxApi will return a null if we have an error response - if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg)) - { - _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", - JsonSerializer.Serialize(model), - JsonSerializer.Serialize(onyxRequest), - JsonSerializer.Serialize(onyxResponse)); - - return Ok(); // return ok so we don't retry - } - - // add the answer as a note to the ticket - await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId); - - return Ok(); - } - - [HttpPost("webhook-onyx-ai-reply")] - public async Task PostWebhookOnyxAiReply([FromQuery, Required] string key, - [FromBody, Required] FreshdeskOnyxAiWebhookModel model) - { - // NOTE: - // at this time, this endpoint is a duplicate of `webhook-onyx-ai` - // eventually, we will merge both endpoints into one webhook for Freshdesk - - // ensure that the key is from Freshdesk - if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid) - { - return new BadRequestResult(); - } - - // if there is no description, then we don't send anything to onyx - if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) - { - return Ok(); - } - - // create the onyx `answer-with-citation` request - var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model); - - // the CallOnyxApi will return a null if we have an error response - if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg)) - { - _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", - JsonSerializer.Serialize(model), - JsonSerializer.Serialize(onyxRequest), - JsonSerializer.Serialize(onyxResponse)); - - return Ok(); // return ok so we don't retry - } - - // add the reply to the ticket - await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId); - - return Ok(); - } - - private bool IsValidRequestFromFreshdesk(string key) - { - if (string.IsNullOrWhiteSpace(key) - || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey)) - { - return false; - } - - return true; - } - - private async Task CreateNote(string ticketId, string note) - { - var noteBody = new Dictionary - { - { "body", $"
      {note}
    " }, - { "private", true } - }; - var noteRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId)) - { - Content = JsonContent.Create(noteBody), - }; - await CallFreshdeskApiAsync(noteRequest); - } - - private async Task AddAnswerNoteToTicketAsync(string note, string ticketId) - { - // if there is no content, then we don't need to add a note - if (string.IsNullOrWhiteSpace(note)) - { - return; - } - - var noteBody = new Dictionary - { - { "body", $"Onyx AI:
      {note}
    " }, - { "private", true } - }; - - var noteRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId)) - { - Content = JsonContent.Create(noteBody), - }; - - var addNoteResponse = await CallFreshdeskApiAsync(noteRequest); - if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created) - { - _logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}", - ticketId, addNoteResponse.ToString()); - } - } - - private async Task AddReplyToTicketAsync(string note, string ticketId) - { - // if there is no content, then we don't need to add a note - if (string.IsNullOrWhiteSpace(note)) - { - return; - } - - // convert note from markdown to html - var htmlNote = note; - try - { - var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); - htmlNote = Markdig.Markdown.ToHtml(note, pipeline); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}", - ticketId, note); - htmlNote = note; // fallback to the original note - } - - // clear out any new lines that Freshdesk doesn't like - if (_billingSettings.FreshDesk.RemoveNewlinesInReplies) - { - htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty); - } - - var replyBody = new FreshdeskReplyRequestModel - { - Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}", - }; - - var replyRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId)) - { - Content = JsonContent.Create(replyBody), - }; - - var addReplyResponse = await CallFreshdeskApiAsync(replyRequest); - if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created) - { - _logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}", - ticketId, addReplyResponse.ToString()); - } - } - - private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) - { - try - { - var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X")); - var httpClient = _httpClientFactory.CreateClient("FreshdeskApi"); - request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}"); - var response = await httpClient.SendAsync(request); - if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3) - { - return response; - } - } - catch - { - if (retriedCount > 3) - { - throw; - } - } - await Task.Delay(30000 * (retriedCount + 1)); - return await CallFreshdeskApiAsync(request, retriedCount++); - } - - async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model) - { - // TODO: remove the use of the deprecated answer-with-citation models after we are sure - if (_billingSettings.Onyx.UseAnswerWithCitationModels) - { - var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx); - var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) - { - Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")), - }; - var onyxResponse = await CallOnyxApi(onyxAnswerWithCitationRequest); - return (onyxRequest, onyxResponse); - } - - var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx); - var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path)) - { - Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")), - }; - var onyxSimpleResponse = await CallOnyxApi(onyxSimpleRequest); - return (request, onyxSimpleResponse); - } - - private async Task CallOnyxApi(HttpRequestMessage request) where T : class, new() - { - var httpClient = _httpClientFactory.CreateClient("OnyxApi"); - var response = await httpClient.SendAsync(request); - - if (response.StatusCode != System.Net.HttpStatusCode.OK) - { - _logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}", - response.StatusCode, JsonSerializer.Serialize(response)); - return new T(); - } - var responseStr = await response.Content.ReadAsStringAsync(); - var responseJson = JsonSerializer.Deserialize(responseStr, options: new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - return responseJson ?? new T(); - } - - private TAttribute? GetAttribute(Enum enumValue) where TAttribute : Attribute - { - var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault(); - return memberInfo != null ? memberInfo.GetCustomAttribute() : null; - } -} diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs deleted file mode 100644 index 68382fbd5d..0000000000 --- a/src/Billing/Controllers/FreshsalesController.cs +++ /dev/null @@ -1,248 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Net.Http.Headers; -using System.Text.Json.Serialization; -using Bit.Core.Billing.Enums; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace Bit.Billing.Controllers; - -[Route("freshsales")] -public class FreshsalesController : Controller -{ - private readonly IUserRepository _userRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; - - private readonly string _freshsalesApiKey; - - private readonly HttpClient _httpClient; - - public FreshsalesController(IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOptions billingSettings, - ILogger logger, - GlobalSettings globalSettings) - { - _userRepository = userRepository; - _organizationRepository = organizationRepository; - _logger = logger; - _globalSettings = globalSettings; - - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://bitwarden.freshsales.io/api/") - }; - - _freshsalesApiKey = billingSettings.Value.FreshsalesApiKey; - - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Token", - $"token={_freshsalesApiKey}"); - } - - - [HttpPost("webhook")] - public async Task PostWebhook([FromHeader(Name = "Authorization")] string key, - [FromBody] CustomWebhookRequestModel request, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key)) - { - return Unauthorized(); - } - - try - { - var leadResponse = await _httpClient.GetFromJsonAsync>( - $"leads/{request.LeadId}", - cancellationToken); - - var lead = leadResponse.Lead; - - var primaryEmail = lead.Emails - .Where(e => e.IsPrimary) - .FirstOrDefault(); - - if (primaryEmail == null) - { - return BadRequest(new { Message = "Lead has not primary email." }); - } - - var user = await _userRepository.GetByEmailAsync(primaryEmail.Value); - - if (user == null) - { - return NoContent(); - } - - var newTags = new HashSet(); - - if (user.Premium) - { - newTags.Add("Premium"); - } - - var noteItems = new List - { - $"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}" - }; - - var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id); - - foreach (var org in orgs) - { - noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}"); - if (TryGetPlanName(org.PlanType, out var planName)) - { - newTags.Add($"Org: {planName}"); - } - } - - if (newTags.Any()) - { - var allTags = newTags.Concat(lead.Tags); - var updateLeadResponse = await _httpClient.PutAsJsonAsync( - $"leads/{request.LeadId}", - CreateWrapper(new { tags = allTags }), - cancellationToken); - updateLeadResponse.EnsureSuccessStatusCode(); - } - - var createNoteResponse = await _httpClient.PostAsJsonAsync( - "notes", - CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken); - createNoteResponse.EnsureSuccessStatusCode(); - return NoContent(); - } - catch (Exception ex) - { - Console.WriteLine(ex); - _logger.LogError(ex, "Error processing freshsales webhook"); - return BadRequest(new { ex.Message }); - } - } - - private static LeadWrapper CreateWrapper(T lead) - { - return new LeadWrapper - { - Lead = lead, - }; - } - - private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content) - { - return new CreateNoteRequestModel - { - Note = new EditNoteModel - { - Description = content, - TargetableType = "Lead", - TargetableId = leadId, - }, - }; - } - - private static bool TryGetPlanName(PlanType planType, out string planName) - { - switch (planType) - { - case PlanType.Free: - planName = "Free"; - return true; - case PlanType.FamiliesAnnually: - case PlanType.FamiliesAnnually2025: - case PlanType.FamiliesAnnually2019: - planName = "Families"; - return true; - case PlanType.TeamsAnnually: - case PlanType.TeamsAnnually2023: - case PlanType.TeamsAnnually2020: - case PlanType.TeamsAnnually2019: - case PlanType.TeamsMonthly: - case PlanType.TeamsMonthly2023: - case PlanType.TeamsMonthly2020: - case PlanType.TeamsMonthly2019: - case PlanType.TeamsStarter: - case PlanType.TeamsStarter2023: - planName = "Teams"; - return true; - case PlanType.EnterpriseAnnually: - case PlanType.EnterpriseAnnually2023: - case PlanType.EnterpriseAnnually2020: - case PlanType.EnterpriseAnnually2019: - case PlanType.EnterpriseMonthly: - case PlanType.EnterpriseMonthly2023: - case PlanType.EnterpriseMonthly2020: - case PlanType.EnterpriseMonthly2019: - planName = "Enterprise"; - return true; - case PlanType.Custom: - planName = "Custom"; - return true; - default: - planName = null; - return false; - } - } -} - -public class CustomWebhookRequestModel -{ - [JsonPropertyName("leadId")] - public long LeadId { get; set; } -} - -public class LeadWrapper -{ - [JsonPropertyName("lead")] - public T Lead { get; set; } - - public static LeadWrapper Create(TItem lead) - { - return new LeadWrapper - { - Lead = lead, - }; - } -} - -public class FreshsalesLeadModel -{ - public string[] Tags { get; set; } - public FreshsalesEmailModel[] Emails { get; set; } -} - -public class FreshsalesEmailModel -{ - [JsonPropertyName("value")] - public string Value { get; set; } - - [JsonPropertyName("is_primary")] - public bool IsPrimary { get; set; } -} - -public class CreateNoteRequestModel -{ - [JsonPropertyName("note")] - public EditNoteModel Note { get; set; } -} - -public class EditNoteModel -{ - [JsonPropertyName("description")] - public string Description { get; set; } - - [JsonPropertyName("targetable_type")] - public string TargetableType { get; set; } - - [JsonPropertyName("targetable_id")] - public long TargetableId { get; set; } -} diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs deleted file mode 100644 index 3927039769..0000000000 --- a/src/Billing/Models/FreshdeskReplyRequestModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class FreshdeskReplyRequestModel -{ - [JsonPropertyName("body")] - public required string Body { get; set; } -} diff --git a/src/Billing/Models/FreshdeskWebhookModel.cs b/src/Billing/Models/FreshdeskWebhookModel.cs deleted file mode 100644 index aac0e9339d..0000000000 --- a/src/Billing/Models/FreshdeskWebhookModel.cs +++ /dev/null @@ -1,24 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class FreshdeskWebhookModel -{ - [JsonPropertyName("ticket_id")] - public string TicketId { get; set; } - - [JsonPropertyName("ticket_contact_email")] - public string TicketContactEmail { get; set; } - - [JsonPropertyName("ticket_tags")] - public string TicketTags { get; set; } -} - -public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel -{ - [JsonPropertyName("ticket_description_text")] - public string TicketDescriptionText { get; set; } -} diff --git a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs deleted file mode 100644 index 9a753be4bc..0000000000 --- a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json.Serialization; -using static Bit.Billing.BillingSettings; - -namespace Bit.Billing.Models; - -public class OnyxRequestModel -{ - [JsonPropertyName("persona_id")] - public int PersonaId { get; set; } = 1; - - [JsonPropertyName("retrieval_options")] - public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions(); - - public OnyxRequestModel(OnyxSettings onyxSettings) - { - PersonaId = onyxSettings.PersonaId; - RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch; - RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime; - } -} - -/// -/// This is used with the onyx endpoint /query/answer-with-citation -/// which has been deprecated. This can be removed once later -/// -public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel -{ - [JsonPropertyName("messages")] - public List Messages { get; set; } = new List(); - - public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) - { - message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); - Messages = new List() { new Message() { MessageText = message } }; - } -} - -/// -/// This is used with the onyx endpoint /chat/send-message-simple-api -/// -public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel -{ - [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; - - public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) - { - Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); - } -} - -public class Message -{ - [JsonPropertyName("message")] - public string MessageText { get; set; } = string.Empty; - - [JsonPropertyName("sender")] - public string Sender { get; set; } = "user"; -} - -public class RetrievalOptions -{ - [JsonPropertyName("run_search")] - public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto; - - [JsonPropertyName("real_time")] - public bool RealTime { get; set; } = true; -} - -public class RetrievalOptionsRunSearch -{ - public const string Always = "always"; - public const string Never = "never"; - public const string Auto = "auto"; -} diff --git a/src/Billing/Models/OnyxResponseModel.cs b/src/Billing/Models/OnyxResponseModel.cs deleted file mode 100644 index 96fa134c40..0000000000 --- a/src/Billing/Models/OnyxResponseModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class OnyxResponseModel -{ - [JsonPropertyName("answer")] - public string Answer { get; set; } = string.Empty; - - [JsonPropertyName("answer_citationless")] - public string AnswerCitationless { get; set; } = string.Empty; - - [JsonPropertyName("error_msg")] - public string ErrorMsg { get; set; } = string.Empty; -} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 1343dc0895..30f4f5f562 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -2,7 +2,6 @@ #nullable disable using System.Globalization; -using System.Net.Http.Headers; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Commercial.Core.Utilities; @@ -98,13 +97,6 @@ public class Startup // Authentication services.AddAuthentication(); - // Set up HttpClients - services.AddHttpClient("FreshdeskApi"); - services.AddHttpClient("OnyxApi", client => - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey); - }); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Billing/appsettings.Development.json b/src/Billing/appsettings.Development.json index fe8e47b2f6..77057fde7f 100644 --- a/src/Billing/appsettings.Development.json +++ b/src/Billing/appsettings.Development.json @@ -32,10 +32,5 @@ "connectionString": "UseDevelopmentStorage=true" } }, - "billingSettings": { - "onyx": { - "personaId": 68 - } - }, "pricingUri": "https://billingpricing.qa.bitwarden.pw" } diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 4be5d51a52..819986181f 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -26,10 +26,7 @@ "payPal": { "production": true, "businessId": "4ZDA7DLUUJGMN" - }, - "onyx": { - "personaId": 7 - } + } }, "Logging": { "IncludeScopes": false, diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index aa14f1d377..7093b6a923 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -61,27 +61,6 @@ "production": false, "businessId": "AD3LAUZSNVPJY", "webhookKey": "SECRET" - }, - "freshdesk": { - "apiKey": "SECRET", - "webhookKey": "SECRET", - "region": "US", - "userFieldName": "cf_user", - "orgFieldName": "cf_org", - "removeNewlinesInReplies": true, - "autoReplyGreeting": "Greetings,

    Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:

    ", - "autoReplySalutation": "

    If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.

    Best Regards,
    The Bitwarden Customer Success Team

    " - }, - "onyx": { - "apiKey": "SECRET", - "baseUrl": "https://cloud.onyx.app/api", - "path": "/chat/send-message-simple-api", - "useAnswerWithCitationModels": true, - "personaId": 7, - "searchSettings": { - "runSearch": "always", - "realTime": true - } } } } diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs deleted file mode 100644 index 5c9199d29a..0000000000 --- a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.Text.Json; -using Bit.Billing.Controllers; -using Bit.Billing.Models; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; -using NSubstitute.ReceivedExtensions; -using Xunit; - -namespace Bit.Billing.Test.Controllers; - -[ControllerCustomize(typeof(FreshdeskController))] -[SutProviderCustomize] -public class FreshdeskControllerTests -{ - private const string ApiKey = "TESTFRESHDESKAPIKEY"; - private const string WebhookKey = "TESTKEY"; - - private const string UserFieldName = "cf_user"; - private const string OrgFieldName = "cf_org"; - - [Theory] - [BitAutoData((string)null, null)] - [BitAutoData((string)null)] - [BitAutoData(WebhookKey, null)] - public async Task PostWebhook_NullRequiredParameters_BadRequest(string freshdeskWebhookKey, FreshdeskWebhookModel model, - BillingSettings billingSettings, SutProvider sutProvider) - { - sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey); - - var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode); - } - - [Theory] - [BitAutoData] - public async Task PostWebhook_Success(User user, FreshdeskWebhookModel model, - List organizations, SutProvider sutProvider) - { - model.TicketContactEmail = user.Email; - - sutProvider.GetDependency().GetByEmailAsync(user.Email).Returns(user); - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(organizations); - - var mockHttpMessageHandler = Substitute.ForPartsOf(); - var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK); - mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) - .Returns(mockResponse); - var httpClient = new HttpClient(mockHttpMessageHandler); - - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); - - sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(WebhookKey); - sutProvider.GetDependency>().Value.FreshDesk.ApiKey.Returns(ApiKey); - sutProvider.GetDependency>().Value.FreshDesk.UserFieldName.Returns(UserFieldName); - sutProvider.GetDependency>().Value.FreshDesk.OrgFieldName.Returns(OrgFieldName); - - var response = await sutProvider.Sut.PostWebhook(WebhookKey, model); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); - - _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Put && m.RequestUri.ToString().EndsWith(model.TicketId)), Arg.Any()); - _ = mockHttpMessageHandler.Received(1).Send(Arg.Is(m => m.Method == HttpMethod.Post && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes")), Arg.Any()); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhook_add_note_when_user_is_invalid( - string freshdeskWebhookKey, FreshdeskWebhookModel model, - SutProvider sutProvider) - { - // Arrange - for an invalid user - model.TicketContactEmail = "invalid@user"; - sutProvider.GetDependency().GetByEmailAsync(model.TicketContactEmail).Returns((User)null); - sutProvider.GetDependency>().Value.FreshDesk.WebhookKey.Returns(WebhookKey); - - var mockHttpMessageHandler = Substitute.ForPartsOf(); - var mockResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK); - mockHttpMessageHandler.Send(Arg.Any(), Arg.Any()) - .Returns(mockResponse); - var httpClient = new HttpClient(mockHttpMessageHandler); - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(httpClient); - - // Act - var response = await sutProvider.Sut.PostWebhook(freshdeskWebhookKey, model); - - // Assert - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); - - await mockHttpMessageHandler - .Received(1).Send( - Arg.Is( - m => m.Method == HttpMethod.Post - && m.RequestUri.ToString().EndsWith($"{model.TicketId}/notes") - && m.Content.ReadAsStringAsync().Result.Contains("No user found")), - Arg.Any()); - } - - - [Theory] - [BitAutoData((string)null, null)] - [BitAutoData((string)null)] - [BitAutoData(WebhookKey, null)] - public async Task PostWebhookOnyxAi_InvalidWebhookKey_results_in_BadRequest( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - BillingSettings billingSettings, SutProvider sutProvider) - { - sutProvider.GetDependency>() - .Value.FreshDesk.WebhookKey.Returns(billingSettings.FreshDesk.WebhookKey); - - var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status400BadRequest, statusCodeResult.StatusCode); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_invalid_onyx_response_results_is_logged( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - SutProvider sutProvider) - { - var billingSettings = sutProvider.GetDependency>().Value; - billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); - billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); - - // mocking freshdesk Api request for ticket info - var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); - var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); - - // mocking Onyx api response given a ticket description - var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); - var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); - mockOnyxHttpMessageHandler.Send(Arg.Any(), Arg.Any()) - .Returns(mockOnyxResponse); - var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); - - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); - sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); - - var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); - - var _logger = sutProvider.GetDependency>(); - - // workaround because _logger.Received(1).LogWarning(...) does not work - _logger.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Log" && c.GetArguments()[1].ToString().Contains("Error getting answer from Onyx AI")); - - // sent call to Onyx API - but we got an error response - _ = mockOnyxHttpMessageHandler.Received(1).Send(Arg.Any(), Arg.Any()); - // did not call freshdesk to add a note since onyx failed - _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_success( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - OnyxResponseModel onyxResponse, - SutProvider sutProvider) - { - var billingSettings = sutProvider.GetDependency>().Value; - billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); - billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); - - // mocking freshdesk api add note request (POST) - var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); - var mockFreshdeskAddNoteResponse = new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); - mockFreshdeskHttpMessageHandler.Send( - Arg.Is(_ => _.Method == HttpMethod.Post), - Arg.Any()) - .Returns(mockFreshdeskAddNoteResponse); - var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); - - // mocking Onyx api response given a ticket description - var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); - onyxResponse.ErrorMsg = "string.Empty"; - var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent(JsonSerializer.Serialize(onyxResponse)) - }; - mockOnyxHttpMessageHandler.Send(Arg.Any(), Arg.Any()) - .Returns(mockOnyxResponse); - var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); - - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); - sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); - - var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); - - var result = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, result.StatusCode); - } - - [Theory] - [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_ticket_description_is_empty_return_success( - string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, - SutProvider sutProvider) - { - var billingSettings = sutProvider.GetDependency>().Value; - billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); - billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); - - model.TicketDescriptionText = " "; // empty description - - // mocking freshdesk api add note request (POST) - var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); - var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); - - // mocking Onyx api response given a ticket description - var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); - var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); - - sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); - sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); - - var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); - - var result = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status200OK, result.StatusCode); - _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); - _ = mockOnyxHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); - } - - public class MockHttpMessageHandler : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Send(request, cancellationToken); - } - - public new virtual Task Send(HttpRequestMessage request, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - } -} diff --git a/test/Billing.Test/Controllers/FreshsalesControllerTests.cs b/test/Billing.Test/Controllers/FreshsalesControllerTests.cs deleted file mode 100644 index c9ae6efb1a..0000000000 --- a/test/Billing.Test/Controllers/FreshsalesControllerTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Bit.Billing.Controllers; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; -using Xunit; - -namespace Bit.Billing.Test.Controllers; - -public class FreshsalesControllerTests -{ - private const string ApiKey = "TEST_FRESHSALES_APIKEY"; - private const string TestLead = "TEST_FRESHSALES_TESTLEAD"; - - private static (FreshsalesController, IUserRepository, IOrganizationRepository) CreateSut( - string freshsalesApiKey) - { - var userRepository = Substitute.For(); - var organizationRepository = Substitute.For(); - - var billingSettings = Options.Create(new BillingSettings - { - FreshsalesApiKey = freshsalesApiKey, - }); - var globalSettings = new GlobalSettings(); - globalSettings.BaseServiceUri.Admin = "https://test.com"; - - var sut = new FreshsalesController( - userRepository, - organizationRepository, - billingSettings, - Substitute.For>(), - globalSettings - ); - - return (sut, userRepository, organizationRepository); - } - - [RequiredEnvironmentTheory(ApiKey, TestLead), EnvironmentData(ApiKey, TestLead)] - public async Task PostWebhook_Success(string freshsalesApiKey, long leadId) - { - // This test is only for development to use: - // `export TEST_FRESHSALES_APIKEY=[apikey]` - // `export TEST_FRESHSALES_TESTLEAD=[lead id]` - // `dotnet test --filter "FullyQualifiedName~FreshsalesControllerTests.PostWebhook_Success"` - var (sut, userRepository, organizationRepository) = CreateSut(freshsalesApiKey); - - var user = new User - { - Id = Guid.NewGuid(), - Email = "test@email.com", - Premium = true, - }; - - userRepository.GetByEmailAsync(user.Email) - .Returns(user); - - organizationRepository.GetManyByUserIdAsync(user.Id) - .Returns(new List - { - new Organization - { - Id = Guid.NewGuid(), - Name = "Test Org", - } - }); - - var response = await sut.PostWebhook(freshsalesApiKey, new CustomWebhookRequestModel - { - LeadId = leadId, - }, new CancellationToken(false)); - - var statusCodeResult = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status204NoContent, statusCodeResult.StatusCode); - } -} From 2dce8722d6d3b5df69e21ca6f929e6b8b68bfd74 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:14:18 -0600 Subject: [PATCH 46/47] Remove unused FF (#6709) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e1ccbbd9b8..b732420e82 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -187,7 +187,6 @@ public static class FeatureFlagKeys /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; - 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"; public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; From a82365b5dfb2caa4fee703b9136843c0ade9f5e9 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:29:28 -0500 Subject: [PATCH 47/47] PM-30125 - IdentityTokenResponse - mark fields as deprecated (#6773) --- .../IdentityServer/RequestValidators/BaseRequestValidator.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 0bdf1d89c2..e07446d49f 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -659,6 +659,7 @@ public abstract class BaseRequestValidator where T : class var customResponse = new Dictionary(); if (!string.IsNullOrWhiteSpace(user.PrivateKey)) { + // PrivateKey usage is now deprecated in favor of AccountKeys customResponse.Add("PrivateKey", user.PrivateKey); var accountKeys = await _accountKeysQuery.Run(user); customResponse.Add("AccountKeys", new PrivateKeysResponseModel(accountKeys)); @@ -666,6 +667,7 @@ public abstract class BaseRequestValidator where T : class if (!string.IsNullOrWhiteSpace(user.Key)) { + // Key is deprecated in favor of UserDecryptionOptions.MasterPasswordUnlock.MasterKeyEncryptedUserKey customResponse.Add("Key", user.Key); }