From ad2ea4ca21a7ca770fd04b123638d6b8c8578dc8 Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Thu, 30 Jan 2025 10:26:34 -0500
Subject: [PATCH 01/22] Don't enable tax for customer without tax info (#5347)
---
.../Implementations/OrganizationBillingService.cs | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
index 201de22525..1bc4f792d7 100644
--- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
+++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
@@ -356,11 +356,20 @@ public class OrganizationBillingService(
}
}
+ var customerHasTaxInfo = customer is
+ {
+ Address:
+ {
+ Country: not null and not "",
+ PostalCode: not null and not ""
+ }
+ };
+
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
- Enabled = true
+ Enabled = customerHasTaxInfo
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
From 23dce5810326b5a307ebdbd6bc09528964de5222 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 30 Jan 2025 10:58:14 -0500
Subject: [PATCH 02/22] [deps] Billing: Update xunit to 2.9.3 (#5289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.../Infrastructure.Dapper.Test.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj
index 65041c3023..1ba67ba61e 100644
--- a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj
+++ b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj
@@ -11,7 +11,7 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
From 443a147433d30c45ddff1f8b80e626e890253197 Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Thu, 30 Jan 2025 11:55:05 -0500
Subject: [PATCH 03/22] Replace StripePaymentService with
PremiumUserBillingService in ReplacePaymentMethodAsync call (#5350)
---
.../Services/IPremiumUserBillingService.cs | 8 ++++++-
.../PremiumUserBillingService.cs | 23 +++++++++++++++++++
.../Services/Implementations/UserService.cs | 11 +++++----
3 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/src/Core/Billing/Services/IPremiumUserBillingService.cs b/src/Core/Billing/Services/IPremiumUserBillingService.cs
index f74bf6c8da..2161b247b9 100644
--- a/src/Core/Billing/Services/IPremiumUserBillingService.cs
+++ b/src/Core/Billing/Services/IPremiumUserBillingService.cs
@@ -1,4 +1,5 @@
-using Bit.Core.Billing.Models.Sales;
+using Bit.Core.Billing.Models;
+using Bit.Core.Billing.Models.Sales;
using Bit.Core.Entities;
namespace Bit.Core.Billing.Services;
@@ -27,4 +28,9 @@ public interface IPremiumUserBillingService
///
///
Task Finalize(PremiumUserSale sale);
+
+ Task UpdatePaymentMethod(
+ User user,
+ TokenizedPaymentSource tokenizedPaymentSource,
+ TaxInformation taxInformation);
}
diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs
index 0672a8d5e7..ed841c9576 100644
--- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs
+++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs
@@ -1,5 +1,6 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
+using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -58,6 +59,28 @@ public class PremiumUserBillingService(
await userRepository.ReplaceAsync(user);
}
+ public async Task UpdatePaymentMethod(
+ User user,
+ TokenizedPaymentSource tokenizedPaymentSource,
+ TaxInformation taxInformation)
+ {
+ if (string.IsNullOrEmpty(user.GatewayCustomerId))
+ {
+ var customer = await CreateCustomerAsync(user,
+ new CustomerSetup { TokenizedPaymentSource = tokenizedPaymentSource, TaxInformation = taxInformation });
+
+ user.Gateway = GatewayType.Stripe;
+ user.GatewayCustomerId = customer.Id;
+
+ await userRepository.ReplaceAsync(user);
+ }
+ else
+ {
+ await subscriberService.UpdatePaymentSource(user, tokenizedPaymentSource);
+ await subscriberService.UpdateTaxInformation(user, taxInformation);
+ }
+ }
+
private async Task CreateCustomerAsync(
User user,
CustomerSetup customerSetup)
diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs
index 157bfd3a6e..11d4042def 100644
--- a/src/Core/Services/Implementations/UserService.cs
+++ b/src/Core/Services/Implementations/UserService.cs
@@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
@@ -1044,11 +1045,11 @@ public class UserService : UserManager, IUserService, IDisposable
throw new BadRequestException("Invalid token.");
}
- var updated = await _paymentService.UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, taxInfo: taxInfo);
- if (updated)
- {
- await SaveUserAsync(user);
- }
+ var tokenizedPaymentSource = new TokenizedPaymentSource(paymentMethodType, paymentToken);
+ var taxInformation = TaxInformation.From(taxInfo);
+
+ await _premiumUserBillingService.UpdatePaymentMethod(user, tokenizedPaymentSource, taxInformation);
+ await SaveUserAsync(user);
}
public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null)
From 5efd68cf51ab7437a6e92b4279c3dc8f73ac21db Mon Sep 17 00:00:00 2001
From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com>
Date: Thu, 30 Jan 2025 11:07:02 -0600
Subject: [PATCH 04/22] [PM-17562] Initial POC of Distributed Events (#5323)
* Initial POC of Distributed Events
* Apply suggestions from code review
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
* Clean up files to support accepted changes. Address PR Feedback
* Removed unneeded using to fix lint warning
* Moved config into a common EventLogging top-level item. Fixed issues from PR review
* Optimized per suggestion from justinbaur
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
* Updated to add IAsyncDisposable as suggested in PR review
* Updated with suggestion to use KeyedSingleton for the IEventWriteService
* Changed key case to lowercase
---------
Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
---
dev/.env.example | 6 +-
dev/docker-compose.yml | 15 +++
.../RabbitMqEventHttpPostListener.cs | 35 +++++++
.../RabbitMqEventListenerBase.cs | 93 +++++++++++++++++++
.../RabbitMqEventRepositoryListener.cs | 29 ++++++
.../RabbitMqEventWriteService.cs | 65 +++++++++++++
src/Core/Core.csproj | 5 +-
src/Core/Settings/GlobalSettings.cs | 39 ++++++++
src/Events/Startup.cs | 16 ++++
.../Utilities/ServiceCollectionExtensions.cs | 12 ++-
10 files changed, 311 insertions(+), 4 deletions(-)
create mode 100644 src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs
create mode 100644 src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs
create mode 100644 src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs
create mode 100644 src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs
diff --git a/dev/.env.example b/dev/.env.example
index d0ebf50efb..f0aed83a59 100644
--- a/dev/.env.example
+++ b/dev/.env.example
@@ -20,4 +20,8 @@ IDP_SP_ACS_URL=http://localhost:51822/saml2/yourOrgIdHere/Acs
# Optional reverse proxy configuration
# Should match server listen ports in reverse-proxy.conf
API_PROXY_PORT=4100
-IDENTITY_PROXY_PORT=33756
\ No newline at end of file
+IDENTITY_PROXY_PORT=33756
+
+# Optional RabbitMQ configuration
+RABBITMQ_DEFAULT_USER=bitwarden
+RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123
diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml
index c02d3c872b..d23eaefbb0 100644
--- a/dev/docker-compose.yml
+++ b/dev/docker-compose.yml
@@ -84,6 +84,20 @@ services:
profiles:
- idp
+ rabbitmq:
+ image: rabbitmq:management
+ container_name: rabbitmq
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ environment:
+ RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
+ RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
+ volumes:
+ - rabbitmq_data:/var/lib/rabbitmq_data
+ profiles:
+ - rabbitmq
+
reverse-proxy:
image: nginx:alpine
container_name: reverse-proxy
@@ -99,3 +113,4 @@ volumes:
mssql_dev_data:
postgres_dev_data:
mysql_dev_data:
+ rabbitmq_data:
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs
new file mode 100644
index 0000000000..5a875f9278
--- /dev/null
+++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs
@@ -0,0 +1,35 @@
+using System.Net.Http.Json;
+using Bit.Core.Models.Data;
+using Bit.Core.Settings;
+using Microsoft.Extensions.Logging;
+
+namespace Bit.Core.Services;
+
+public class RabbitMqEventHttpPostListener : RabbitMqEventListenerBase
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _httpPostUrl;
+ private readonly string _queueName;
+
+ protected override string QueueName => _queueName;
+
+ public const string HttpClientName = "EventHttpPostListenerHttpClient";
+
+ public RabbitMqEventHttpPostListener(
+ IHttpClientFactory httpClientFactory,
+ ILogger logger,
+ GlobalSettings globalSettings)
+ : base(logger, globalSettings)
+ {
+ _httpClient = httpClientFactory.CreateClient(HttpClientName);
+ _httpPostUrl = globalSettings.EventLogging.RabbitMq.HttpPostUrl;
+ _queueName = globalSettings.EventLogging.RabbitMq.HttpPostQueueName;
+ }
+
+ protected override async Task HandleMessageAsync(EventMessage eventMessage)
+ {
+ var content = JsonContent.Create(eventMessage);
+ var response = await _httpClient.PostAsync(_httpPostUrl, content);
+ response.EnsureSuccessStatusCode();
+ }
+}
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs
new file mode 100644
index 0000000000..48a549d261
--- /dev/null
+++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs
@@ -0,0 +1,93 @@
+using System.Text.Json;
+using Bit.Core.Models.Data;
+using Bit.Core.Settings;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
+
+namespace Bit.Core.Services;
+
+public abstract class RabbitMqEventListenerBase : BackgroundService
+{
+ private IChannel _channel;
+ private IConnection _connection;
+ private readonly string _exchangeName;
+ private readonly ConnectionFactory _factory;
+ private readonly ILogger _logger;
+
+ protected abstract string QueueName { get; }
+
+ protected RabbitMqEventListenerBase(
+ ILogger logger,
+ GlobalSettings globalSettings)
+ {
+ _factory = new ConnectionFactory
+ {
+ HostName = globalSettings.EventLogging.RabbitMq.HostName,
+ UserName = globalSettings.EventLogging.RabbitMq.Username,
+ Password = globalSettings.EventLogging.RabbitMq.Password
+ };
+ _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
+ _logger = logger;
+ }
+
+ public override async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _connection = await _factory.CreateConnectionAsync(cancellationToken);
+ _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
+
+ await _channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true);
+ await _channel.QueueDeclareAsync(queue: QueueName,
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: null,
+ cancellationToken: cancellationToken);
+ await _channel.QueueBindAsync(queue: QueueName,
+ exchange: _exchangeName,
+ routingKey: string.Empty,
+ cancellationToken: cancellationToken);
+ await base.StartAsync(cancellationToken);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var consumer = new AsyncEventingBasicConsumer(_channel);
+ consumer.ReceivedAsync += async (_, eventArgs) =>
+ {
+ try
+ {
+ var eventMessage = JsonSerializer.Deserialize(eventArgs.Body.Span);
+ await HandleMessageAsync(eventMessage);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An error occurred while processing the message");
+ }
+ };
+
+ await _channel.BasicConsumeAsync(QueueName, autoAck: true, consumer: consumer, cancellationToken: stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await Task.Delay(1_000, stoppingToken);
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _channel.CloseAsync();
+ await _connection.CloseAsync();
+ await base.StopAsync(cancellationToken);
+ }
+
+ public override void Dispose()
+ {
+ _channel.Dispose();
+ _connection.Dispose();
+ base.Dispose();
+ }
+
+ protected abstract Task HandleMessageAsync(EventMessage eventMessage);
+}
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs
new file mode 100644
index 0000000000..25d85bddeb
--- /dev/null
+++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs
@@ -0,0 +1,29 @@
+using Bit.Core.Models.Data;
+using Bit.Core.Settings;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Bit.Core.Services;
+
+public class RabbitMqEventRepositoryListener : RabbitMqEventListenerBase
+{
+ private readonly IEventWriteService _eventWriteService;
+ private readonly string _queueName;
+
+ protected override string QueueName => _queueName;
+
+ public RabbitMqEventRepositoryListener(
+ [FromKeyedServices("persistent")] IEventWriteService eventWriteService,
+ ILogger logger,
+ GlobalSettings globalSettings)
+ : base(logger, globalSettings)
+ {
+ _eventWriteService = eventWriteService;
+ _queueName = globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName;
+ }
+
+ protected override Task HandleMessageAsync(EventMessage eventMessage)
+ {
+ return _eventWriteService.CreateAsync(eventMessage);
+ }
+}
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs
new file mode 100644
index 0000000000..d89cf890ac
--- /dev/null
+++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+using Bit.Core.Models.Data;
+using Bit.Core.Settings;
+using RabbitMQ.Client;
+
+namespace Bit.Core.Services;
+public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable
+{
+ private readonly ConnectionFactory _factory;
+ private readonly Lazy> _lazyConnection;
+ private readonly string _exchangeName;
+
+ public RabbitMqEventWriteService(GlobalSettings globalSettings)
+ {
+ _factory = new ConnectionFactory
+ {
+ HostName = globalSettings.EventLogging.RabbitMq.HostName,
+ UserName = globalSettings.EventLogging.RabbitMq.Username,
+ Password = globalSettings.EventLogging.RabbitMq.Password
+ };
+ _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
+
+ _lazyConnection = new Lazy>(CreateConnectionAsync);
+ }
+
+ public async Task CreateAsync(IEvent e)
+ {
+ var connection = await _lazyConnection.Value;
+ using var channel = await connection.CreateChannelAsync();
+
+ await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true);
+
+ var body = JsonSerializer.SerializeToUtf8Bytes(e);
+
+ await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body);
+ }
+
+ public async Task CreateManyAsync(IEnumerable events)
+ {
+ var connection = await _lazyConnection.Value;
+ using var channel = await connection.CreateChannelAsync();
+ await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true);
+
+ foreach (var e in events)
+ {
+ var body = JsonSerializer.SerializeToUtf8Bytes(e);
+
+ await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_lazyConnection.IsValueCreated)
+ {
+ var connection = await _lazyConnection.Value;
+ await connection.DisposeAsync();
+ }
+ }
+
+ private async Task CreateConnectionAsync()
+ {
+ return await _factory.CreateConnectionAsync();
+ }
+}
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 7a5f7e2543..210a33f3f7 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -40,7 +40,7 @@
-
+
@@ -70,12 +70,13 @@
+
-
+
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index 97d66aed53..718293891b 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -53,6 +53,7 @@ public class GlobalSettings : IGlobalSettings
public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings();
public virtual SqlSettings MySql { get; set; } = new SqlSettings();
public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" };
+ 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();
@@ -256,6 +257,44 @@ public class GlobalSettings : IGlobalSettings
}
}
+ public class EventLoggingSettings
+ {
+ public RabbitMqSettings RabbitMq { get; set; }
+
+ public class RabbitMqSettings
+ {
+ private string _hostName;
+ private string _username;
+ private string _password;
+ private string _exchangeName;
+
+ public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue";
+ public virtual string HttpPostQueueName { get; set; } = "events-httpPost-queue";
+ public virtual string HttpPostUrl { get; set; }
+
+ public string HostName
+ {
+ get => _hostName;
+ set => _hostName = value.Trim('"');
+ }
+ public string Username
+ {
+ get => _username;
+ set => _username = value.Trim('"');
+ }
+ public string Password
+ {
+ get => _password;
+ set => _password = value.Trim('"');
+ }
+ public string ExchangeName
+ {
+ get => _exchangeName;
+ set => _exchangeName = value.Trim('"');
+ }
+ }
+ }
+
public class ConnectionStringSettings : IConnectionStringSettings
{
private string _connectionString;
diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs
index bac39c68dd..03e99f14e8 100644
--- a/src/Events/Startup.cs
+++ b/src/Events/Startup.cs
@@ -82,6 +82,22 @@ public class Startup
{
services.AddHostedService();
}
+
+ // Optional RabbitMQ Listeners
+ if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
+ {
+ services.AddKeyedSingleton("persistent");
+ services.AddHostedService();
+
+ if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HttpPostUrl))
+ {
+ services.AddHttpClient(RabbitMqEventHttpPostListener.HttpClientName);
+ services.AddHostedService();
+ }
+ }
}
public void Configure(
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index e1369d5366..622b3d7f39 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -325,7 +325,17 @@ public static class ServiceCollectionExtensions
}
else if (globalSettings.SelfHosted)
{
- services.AddSingleton();
+ if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
+ CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
+ {
+ services.AddSingleton();
+ }
+ else
+ {
+ services.AddSingleton();
+ }
}
else
{
From ab0cab2072d7c925752ee7aa63125cbe2f10ed22 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Thu, 30 Jan 2025 13:59:58 -0500
Subject: [PATCH 05/22] Fix Events Startup (#5352)
---
src/Core/Settings/GlobalSettings.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs
index 718293891b..d039102eb9 100644
--- a/src/Core/Settings/GlobalSettings.cs
+++ b/src/Core/Settings/GlobalSettings.cs
@@ -259,7 +259,7 @@ public class GlobalSettings : IGlobalSettings
public class EventLoggingSettings
{
- public RabbitMqSettings RabbitMq { get; set; }
+ public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings();
public class RabbitMqSettings
{
From 148a6311783b2db87eab6781327570d26eec6e5e Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 31 Jan 2025 15:59:39 +0100
Subject: [PATCH 06/22] [deps]: Update github/codeql-action action to v3.28.8
(#5292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/build.yml | 2 +-
.github/workflows/scan.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7d64612aba..3b96eeb468 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -314,7 +314,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
- uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
+ uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml
index 156ebee165..ec2eb7789a 100644
--- a/.github/workflows/scan.yml
+++ b/.github/workflows/scan.yml
@@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
- uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
+ uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: cx_result.sarif
From d239170c1ca3996703c59ea3a46cf6315b925a53 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rui=20Tom=C3=A9?=
<108268980+r-tome@users.noreply.github.com>
Date: Fri, 31 Jan 2025 15:01:26 +0000
Subject: [PATCH 07/22] [PM-17697] Save Organization Name changes in Bitwarden
Portal (#5337)
* Add Org_Name_Edit permission to the Permissions enum
* Add Org_Name_Edit permission to RolePermissionMapping
* Implement Org_Name_Edit permission check in UpdateOrganization method
* Add Org_Name_Edit permission check to Organization form input
---
.../AdminConsole/Controllers/OrganizationsController.cs | 5 +++++
src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml | 3 ++-
src/Admin/Enums/Permissions.cs | 1 +
src/Admin/Utilities/RolePermissionMapping.cs | 5 +++++
4 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs
index 3fdef169b4..60a5a39612 100644
--- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs
+++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs
@@ -421,6 +421,11 @@ public class OrganizationsController : Controller
private void UpdateOrganization(Organization organization, OrganizationEditModel model)
{
+ if (_accessControlService.UserHasPermission(Permission.Org_Name_Edit))
+ {
+ organization.Name = WebUtility.HtmlEncode(model.Name);
+ }
+
if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox))
{
organization.Enabled = model.Enabled;
diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml
index cdc7608675..aeff65c900 100644
--- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml
+++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml
@@ -12,6 +12,7 @@
var canViewBilling = AccessControlService.UserHasPermission(Permission.Org_Billing_View);
var canViewPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_View);
var canViewLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_View);
+ var canEditName = AccessControlService.UserHasPermission(Permission.Org_Name_Edit);
var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Org_CheckEnabledBox);
var canEditPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_Edit);
var canEditLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_Edit);
@@ -28,7 +29,7 @@
diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs
index 20c500c061..4edcd742b4 100644
--- a/src/Admin/Enums/Permissions.cs
+++ b/src/Admin/Enums/Permissions.cs
@@ -22,6 +22,7 @@ public enum Permission
Org_List_View,
Org_OrgInformation_View,
Org_GeneralDetails_View,
+ Org_Name_Edit,
Org_CheckEnabledBox,
Org_BusinessInformation_View,
Org_InitiateTrial,
diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs
index 4b5a4e3802..3b510781be 100644
--- a/src/Admin/Utilities/RolePermissionMapping.cs
+++ b/src/Admin/Utilities/RolePermissionMapping.cs
@@ -24,6 +24,7 @@ public static class RolePermissionMapping
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
+ Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@@ -71,6 +72,7 @@ public static class RolePermissionMapping
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
+ Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@@ -116,6 +118,7 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway,
Permission.User_NewDeviceException_Edit,
+ Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@@ -148,6 +151,7 @@ public static class RolePermissionMapping
Permission.User_Billing_View,
Permission.User_Billing_Edit,
Permission.User_Billing_LaunchGateway,
+ Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
@@ -185,6 +189,7 @@ public static class RolePermissionMapping
Permission.User_Premium_View,
Permission.User_Licensing_View,
Permission.User_Licensing_Edit,
+ Permission.Org_Name_Edit,
Permission.Org_CheckEnabledBox,
Permission.Org_List_View,
Permission.Org_OrgInformation_View,
From e43a8011f10d94d9ca3271fe2a21e703ce6023d1 Mon Sep 17 00:00:00 2001
From: Todd Martin <106564991+trmartin4@users.noreply.github.com>
Date: Fri, 31 Jan 2025 10:46:09 -0500
Subject: [PATCH 08/22] [PM-17709] Send New Device Login email for all new
devices (#5340)
* Send New Device Login email regardless of New Device Verification
* Adjusted tests
* Linting
* Clarified test names.
---
.../RequestValidators/DeviceValidator.cs | 34 ++++++++++---------
.../IdentityServer/DeviceValidatorTests.cs | 12 ++-----
2 files changed, 21 insertions(+), 25 deletions(-)
diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
index 1b148c5974..fee10e10ff 100644
--- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs
@@ -85,28 +85,17 @@ public class DeviceValidator(
}
}
- // At this point we have established either new device verification is not required or the NewDeviceOtp is valid
+ // At this point we have established either new device verification is not required or the NewDeviceOtp is valid,
+ // so we save the device to the database and proceed with authentication
requestDevice.UserId = context.User.Id;
await _deviceService.SaveAsync(requestDevice);
context.Device = requestDevice;
- // backwards compatibility -- If NewDeviceVerification not enabled send the new login emails
- // PM-13340: removal Task; remove entire if block emails should no longer be sent
- if (!_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification))
+ if (!_globalSettings.DisableEmailNewDevice)
{
- // This ensures the user doesn't receive a "new device" email on the first login
- var now = DateTime.UtcNow;
- if (now - context.User.CreationDate > TimeSpan.FromMinutes(10))
- {
- var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
- .FirstOrDefault()?.GetCustomAttribute()?.GetName();
- if (!_globalSettings.DisableEmailNewDevice)
- {
- await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now,
- _currentContext.IpAddress);
- }
- }
+ await SendNewDeviceLoginEmail(context.User, requestDevice);
}
+
return true;
}
@@ -174,6 +163,19 @@ public class DeviceValidator(
return DeviceValidationResultType.NewDeviceVerificationRequired;
}
+ private async Task SendNewDeviceLoginEmail(User user, Device requestDevice)
+ {
+ // Ensure that the user doesn't receive a "new device" email on the first login
+ var now = DateTime.UtcNow;
+ if (now - user.CreationDate > TimeSpan.FromMinutes(10))
+ {
+ var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString())
+ .FirstOrDefault()?.GetCustomAttribute()?.GetName();
+ await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
+ _currentContext.IpAddress);
+ }
+ }
+
public async Task GetKnownDeviceAsync(User user, Device device)
{
if (user == null || device == null)
diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
index fa3a117c55..6e6406f16b 100644
--- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs
@@ -227,7 +227,7 @@ public class DeviceValidatorTests
}
[Theory, BitAutoData]
- public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_SendsEmail_ReturnsTrue(
+ public async void ValidateRequestDeviceAsync_ExistingUserNewDeviceLogin_SendNewDeviceLoginEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
@@ -237,8 +237,6 @@ public class DeviceValidatorTests
_globalSettings.DisableEmailNewDevice = false;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
- _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
- .Returns(false);
// set user creation to more than 10 minutes ago
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11);
@@ -253,7 +251,7 @@ public class DeviceValidatorTests
}
[Theory, BitAutoData]
- public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_NewUser_DoesNotSendEmail_ReturnsTrue(
+ public async void ValidateRequestDeviceAsync_NewUserNewDeviceLogin_DoesNotSendNewDeviceLoginEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
@@ -263,8 +261,6 @@ public class DeviceValidatorTests
_globalSettings.DisableEmailNewDevice = false;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
- _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
- .Returns(false);
// set user creation to less than 10 minutes ago
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(9);
@@ -279,7 +275,7 @@ public class DeviceValidatorTests
}
[Theory, BitAutoData]
- public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_DisableEmailTrue_DoesNotSendEmail_ReturnsTrue(
+ public async void ValidateRequestDeviceAsynce_DisableNewDeviceLoginEmailTrue_DoesNotSendNewDeviceEmail_ReturnsTrue(
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
@@ -289,8 +285,6 @@ public class DeviceValidatorTests
_globalSettings.DisableEmailNewDevice = true;
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
- _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)
- .Returns(false);
// Act
var result = await _sut.ValidateRequestDeviceAsync(request, context);
From bd394eabe9c887e43893a961e191e6c0e11e1d08 Mon Sep 17 00:00:00 2001
From: Jimmy Vo
Date: Fri, 31 Jan 2025 10:50:14 -0500
Subject: [PATCH 09/22] [pm-16528] Fix entity framework query (#5333)
---
.../OrganizationDomainRepository.cs | 28 ++---
.../OrganizationDomainRepositoryTests.cs | 118 ++++++++++++++++++
2 files changed, 127 insertions(+), 19 deletions(-)
diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs
index e339c13351..50d791b81b 100644
--- a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs
@@ -46,27 +46,17 @@ public class OrganizationDomainRepository : Repository x.VerifiedDate == null
- && x.JobRunCount != 3
- && x.NextRunDate.Year == date.Year
- && x.NextRunDate.Month == date.Month
- && x.NextRunDate.Day == date.Day
- && x.NextRunDate.Hour == date.Hour)
- .AsNoTracking()
+ var start36HoursWindow = date.AddHours(-36);
+ var end36HoursWindow = date;
+
+ var pastDomains = await dbContext.OrganizationDomains
+ .Where(x => x.NextRunDate >= start36HoursWindow
+ && x.NextRunDate <= end36HoursWindow
+ && x.VerifiedDate == null
+ && x.JobRunCount != 3)
.ToListAsync();
- //Get records that have ignored/failed by the background service
- var pastDomains = dbContext.OrganizationDomains
- .AsEnumerable()
- .Where(x => (date - x.NextRunDate).TotalHours > 36
- && x.VerifiedDate == null
- && x.JobRunCount != 3)
- .ToList();
-
- var results = domains.Union(pastDomains);
-
- return Mapper.Map>(results);
+ return Mapper.Map>(pastDomains);
}
public async Task GetOrganizationDomainSsoDetailsAsync(string email)
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs
index 8e0b502a47..ad92f40efc 100644
--- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs
@@ -188,4 +188,122 @@ public class OrganizationDomainRepositoryTests
var expectedDomain2 = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain2.DomainName);
Assert.Null(expectedDomain2);
}
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByNextRunDateAsync_ShouldReturnUnverifiedDomains(
+ IOrganizationRepository organizationRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ // Arrange
+ var id = Guid.NewGuid();
+
+ var organization1 = await organizationRepository.CreateAsync(new Organization
+ {
+ Name = $"Test Org {id}",
+ BillingEmail = $"test+{id}@example.com",
+ Plan = "Test",
+ PrivateKey = "privatekey",
+
+ });
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization1.Id,
+ DomainName = $"domain2+{id}@example.com",
+ Txt = "btw+12345"
+ };
+
+ var within36HoursWindow = 1;
+ organizationDomain.SetNextRunDate(within36HoursWindow);
+
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ var date = organizationDomain.NextRunDate;
+
+ // Act
+ var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);
+
+ // Assert
+ var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName);
+ Assert.NotNull(expectedDomain);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByNextRunDateAsync_ShouldNotReturnUnverifiedDomains_WhenNextRunDateIsOutside36hoursWindow(
+ IOrganizationRepository organizationRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ // Arrange
+ var id = Guid.NewGuid();
+
+ var organization1 = await organizationRepository.CreateAsync(new Organization
+ {
+ Name = $"Test Org {id}",
+ BillingEmail = $"test+{id}@example.com",
+ Plan = "Test",
+ PrivateKey = "privatekey",
+
+ });
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization1.Id,
+ DomainName = $"domain2+{id}@example.com",
+ Txt = "btw+12345"
+ };
+
+ var outside36HoursWindow = 20;
+ organizationDomain.SetNextRunDate(outside36HoursWindow);
+
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ var date = DateTimeOffset.UtcNow.Date.AddDays(1);
+
+ // Act
+ var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);
+
+ // Assert
+ var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName);
+ Assert.Null(expectedDomain);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetManyByNextRunDateAsync_ShouldNotReturnVerifiedDomains(
+ IOrganizationRepository organizationRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ // Arrange
+ var id = Guid.NewGuid();
+
+ var organization1 = await organizationRepository.CreateAsync(new Organization
+ {
+ Name = $"Test Org {id}",
+ BillingEmail = $"test+{id}@example.com",
+ Plan = "Test",
+ PrivateKey = "privatekey",
+
+ });
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization1.Id,
+ DomainName = $"domain2+{id}@example.com",
+ Txt = "btw+12345"
+ };
+
+ var within36HoursWindow = 1;
+ organizationDomain.SetNextRunDate(within36HoursWindow);
+ organizationDomain.SetVerifiedDate();
+
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ var date = DateTimeOffset.UtcNow.Date.AddDays(1);
+
+ // Act
+ var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date);
+
+ // Assert
+ var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName);
+ Assert.Null(expectedDomain);
+ }
}
From 408ddd938893448cfce270ef0754168d4d65e037 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Fri, 31 Jan 2025 11:08:07 -0500
Subject: [PATCH 10/22] Scaffold Events Integration Tests (#5355)
* Scaffold Events Integration Tests
* Format
---
bitwarden-server.sln | 7 ++
.../Controllers/CollectControllerTests.cs | 29 ++++++++
.../Events.IntegrationTest.csproj | 29 ++++++++
.../EventsApplicationFactory.cs | 57 +++++++++++++++
test/Events.IntegrationTest/GlobalUsings.cs | 1 +
.../Factories/WebApplicationFactoryBase.cs | 72 ++++++++++---------
6 files changed, 163 insertions(+), 32 deletions(-)
create mode 100644 test/Events.IntegrationTest/Controllers/CollectControllerTests.cs
create mode 100644 test/Events.IntegrationTest/Events.IntegrationTest.csproj
create mode 100644 test/Events.IntegrationTest/EventsApplicationFactory.cs
create mode 100644 test/Events.IntegrationTest/GlobalUsings.cs
diff --git a/bitwarden-server.sln b/bitwarden-server.sln
index 75e7d7fade..e9aff53f8e 100644
--- a/bitwarden-server.sln
+++ b/bitwarden-server.sln
@@ -125,6 +125,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -313,6 +315,10 @@ Global
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -363,6 +369,7 @@ Global
{81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
+ {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
diff --git a/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs
new file mode 100644
index 0000000000..7f86758144
--- /dev/null
+++ b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs
@@ -0,0 +1,29 @@
+using System.Net.Http.Json;
+using Bit.Core.Enums;
+using Bit.Events.Models;
+
+namespace Bit.Events.IntegrationTest.Controllers;
+
+public class CollectControllerTests
+{
+ // This is a very simple test, and should be updated to assert more things, but for now
+ // it ensures that the events startup doesn't throw any errors with fairly basic configuration.
+ [Fact]
+ public async Task Post_Works()
+ {
+ var eventsApplicationFactory = new EventsApplicationFactory();
+ var (accessToken, _) = await eventsApplicationFactory.LoginWithNewAccount();
+ var client = eventsApplicationFactory.CreateAuthedClient(accessToken);
+
+ var response = await client.PostAsJsonAsync>("collect",
+ [
+ new EventModel
+ {
+ Type = EventType.User_ClientExportedVault,
+ Date = DateTime.UtcNow,
+ },
+ ]);
+
+ response.EnsureSuccessStatusCode();
+ }
+}
diff --git a/test/Events.IntegrationTest/Events.IntegrationTest.csproj b/test/Events.IntegrationTest/Events.IntegrationTest.csproj
new file mode 100644
index 0000000000..0b51185298
--- /dev/null
+++ b/test/Events.IntegrationTest/Events.IntegrationTest.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs
new file mode 100644
index 0000000000..3faf5e81bf
--- /dev/null
+++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs
@@ -0,0 +1,57 @@
+using Bit.Identity.Models.Request.Accounts;
+using Bit.IntegrationTestCommon.Factories;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Events.IntegrationTest;
+
+public class EventsApplicationFactory : WebApplicationFactoryBase
+{
+ private readonly IdentityApplicationFactory _identityApplicationFactory;
+ private const string _connectionString = "DataSource=:memory:";
+
+ public EventsApplicationFactory()
+ {
+ SqliteConnection = new SqliteConnection(_connectionString);
+ SqliteConnection.Open();
+
+ _identityApplicationFactory = new IdentityApplicationFactory();
+ _identityApplicationFactory.SqliteConnection = SqliteConnection;
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+
+ builder.ConfigureTestServices(services =>
+ {
+ services.Configure(JwtBearerDefaults.AuthenticationScheme, options =>
+ {
+ options.BackchannelHttpHandler = _identityApplicationFactory.Server.CreateHandler();
+ });
+ });
+ }
+
+ ///
+ /// Helper for registering and logging in to a new account
+ ///
+ public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash")
+ {
+ await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel
+ {
+ Email = email,
+ MasterPasswordHash = masterPasswordHash,
+ });
+
+ return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ SqliteConnection!.Dispose();
+ }
+}
diff --git a/test/Events.IntegrationTest/GlobalUsings.cs b/test/Events.IntegrationTest/GlobalUsings.cs
new file mode 100644
index 0000000000..9df1d42179
--- /dev/null
+++ b/test/Events.IntegrationTest/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
index d01e92ad4c..7c7f790cdc 100644
--- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
+++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs
@@ -14,6 +14,7 @@ using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
@@ -188,44 +189,27 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory
// QUESTION: The normal licensing service should run fine on developer machines but not in CI
// should we have a fork here to leave the normal service for developers?
// TODO: Eventually add the license file to CI
- var licensingService = services.First(sd => sd.ServiceType == typeof(ILicensingService));
- services.Remove(licensingService);
- services.AddSingleton();
+ Replace(services);
// FUTURE CONSIDERATION: Add way to run this self hosted/cloud, for now it is cloud only
- var pushRegistrationService = services.First(sd => sd.ServiceType == typeof(IPushRegistrationService));
- services.Remove(pushRegistrationService);
- services.AddSingleton();
+ Replace(services);
// Even though we are cloud we currently set this up as cloud, we can use the EF/selfhosted service
// instead of using Noop for this service
// TODO: Install and use azurite in CI pipeline
- var eventWriteService = services.First(sd => sd.ServiceType == typeof(IEventWriteService));
- services.Remove(eventWriteService);
- services.AddSingleton();
+ Replace(services);
- var eventRepositoryService = services.First(sd => sd.ServiceType == typeof(IEventRepository));
- services.Remove(eventRepositoryService);
- services.AddSingleton();
+ Replace(services);
- var mailDeliveryService = services.First(sd => sd.ServiceType == typeof(IMailDeliveryService));
- services.Remove(mailDeliveryService);
- services.AddSingleton();
+ Replace(services);
- var captchaValidationService = services.First(sd => sd.ServiceType == typeof(ICaptchaValidationService));
- services.Remove(captchaValidationService);
- services.AddSingleton();
+ Replace(services);
// TODO: Install and use azurite in CI pipeline
- var installationDeviceRepository =
- services.First(sd => sd.ServiceType == typeof(IInstallationDeviceRepository));
- services.Remove(installationDeviceRepository);
- services.AddSingleton();
+ Replace(services);
// TODO: Install and use azurite in CI pipeline
- var referenceEventService = services.First(sd => sd.ServiceType == typeof(IReferenceEventService));
- services.Remove(referenceEventService);
- services.AddSingleton();
+ Replace(services);
// Our Rate limiter works so well that it begins to fail tests unless we carve out
// one whitelisted ip. We should still test the rate limiter though and they should change the Ip
@@ -245,14 +229,9 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory
services.AddSingleton();
// Noop StripePaymentService - this could be changed to integrate with our Stripe test account
- var stripePaymentService = services.First(sd => sd.ServiceType == typeof(IPaymentService));
- services.Remove(stripePaymentService);
- services.AddSingleton(Substitute.For());
+ Replace(services, Substitute.For());
- var organizationBillingService =
- services.First(sd => sd.ServiceType == typeof(IOrganizationBillingService));
- services.Remove(organizationBillingService);
- services.AddSingleton(Substitute.For());
+ Replace(services, Substitute.For());
});
foreach (var configureTestService in _configureTestServices)
@@ -261,6 +240,35 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory
}
}
+ private static void Replace(IServiceCollection services)
+ where TService : class
+ where TNewImplementation : class, TService
+ {
+ services.RemoveAll();
+ services.AddSingleton();
+ }
+
+ private static void Replace(IServiceCollection services, TService implementation)
+ where TService : class
+ {
+ services.RemoveAll();
+ services.AddSingleton(implementation);
+ }
+
+ public HttpClient CreateAuthedClient(string accessToken)
+ {
+ var handler = Server.CreateHandler((context) =>
+ {
+ context.Request.Headers.Authorization = $"Bearer {accessToken}";
+ });
+
+ return new HttpClient(handler)
+ {
+ BaseAddress = Server.BaseAddress,
+ Timeout = TimeSpan.FromSeconds(200),
+ };
+ }
+
public DatabaseContext GetDatabaseContext()
{
var scope = Services.CreateScope();
From 669c253bc62639deffd08076843873030d66e223 Mon Sep 17 00:00:00 2001
From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com>
Date: Fri, 31 Jan 2025 12:18:10 -0600
Subject: [PATCH 11/22] chore: add limit item deletion feature flag constant,
refs PM-17214 (#5356)
---
src/Core/Constants.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 6d70f0b3ce..5643ed7654 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -107,6 +107,7 @@ public static class FeatureFlagKeys
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string IntegrationPage = "pm-14505-admin-console-integration-page";
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
+ public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
From 1adc5358a871885483296e2a997a147ab17fd84d Mon Sep 17 00:00:00 2001
From: Robyn MacCallum
Date: Mon, 3 Feb 2025 09:35:38 -0500
Subject: [PATCH 12/22] Create a single feature flag for the Authenticator sync
(#5353)
* Create a single feature flag for the Authenticator sync
* Update feature flag key
---
src/Core/Constants.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 5643ed7654..b196306409 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -171,6 +171,7 @@ public static class FeatureFlagKeys
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
+ public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
public static List GetAllKeys()
{
From fe983aff7f43b076f9c1affeb654eaa9951d2fff Mon Sep 17 00:00:00 2001
From: Jimmy Vo
Date: Mon, 3 Feb 2025 12:35:46 -0500
Subject: [PATCH 13/22] [pm-17911] Refresh OrganizationView (#5360)
---
.../2025-02-03_01_RefreshView_For_LimitItemDeletion.sql | 7 +++++++
1 file changed, 7 insertions(+)
create mode 100644 util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql
diff --git a/util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql b/util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql
new file mode 100644
index 0000000000..98893bb030
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql
@@ -0,0 +1,7 @@
+-- Refresh Views
+
+IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL
+ BEGIN
+ EXECUTE sp_refreshview N'[dbo].[OrganizationView]';
+ END
+GO
From 060e9e60bff549b65cef12586c4fab0ec598a7fe Mon Sep 17 00:00:00 2001
From: Jimmy Vo
Date: Mon, 3 Feb 2025 14:55:57 -0500
Subject: [PATCH 14/22] [pm-337] Remove the continuation token from the
ListResponseModel. (#5192)
---
.../Public/Controllers/EventsController.cs | 4 ++--
src/Api/Models/Public/Response/ListResponseModel.cs | 7 +------
.../Models/Public/Response/PagedListResponseModel.cs | 10 ++++++++++
3 files changed, 13 insertions(+), 8 deletions(-)
create mode 100644 src/Api/Models/Public/Response/PagedListResponseModel.cs
diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/AdminConsole/Public/Controllers/EventsController.cs
index d2e198de17..992b7453aa 100644
--- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs
+++ b/src/Api/AdminConsole/Public/Controllers/EventsController.cs
@@ -36,7 +36,7 @@ public class EventsController : Controller
/// If no filters are provided, it will return the last 30 days of event for the organization.
///
[HttpGet]
- [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(PagedListResponseModel), (int)HttpStatusCode.OK)]
public async Task List([FromQuery] EventFilterRequestModel request)
{
var dateRange = request.ToDateRange();
@@ -65,7 +65,7 @@ public class EventsController : Controller
}
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
- var response = new ListResponseModel(eventResponses, result.ContinuationToken);
+ var response = new PagedListResponseModel(eventResponses, result.ContinuationToken);
return new JsonResult(response);
}
}
diff --git a/src/Api/Models/Public/Response/ListResponseModel.cs b/src/Api/Models/Public/Response/ListResponseModel.cs
index 0865be3e8e..a55d6f62bb 100644
--- a/src/Api/Models/Public/Response/ListResponseModel.cs
+++ b/src/Api/Models/Public/Response/ListResponseModel.cs
@@ -4,10 +4,9 @@ namespace Bit.Api.Models.Public.Response;
public class ListResponseModel : IResponseModel where T : IResponseModel
{
- public ListResponseModel(IEnumerable data, string continuationToken = null)
+ public ListResponseModel(IEnumerable data)
{
Data = data;
- ContinuationToken = continuationToken;
}
///
@@ -21,8 +20,4 @@ public class ListResponseModel : IResponseModel where T : IResponseModel
///
[Required]
public IEnumerable Data { get; set; }
- ///
- /// A cursor for use in pagination.
- ///
- public string ContinuationToken { get; set; }
}
diff --git a/src/Api/Models/Public/Response/PagedListResponseModel.cs b/src/Api/Models/Public/Response/PagedListResponseModel.cs
new file mode 100644
index 0000000000..b0f25cb4f8
--- /dev/null
+++ b/src/Api/Models/Public/Response/PagedListResponseModel.cs
@@ -0,0 +1,10 @@
+namespace Bit.Api.Models.Public.Response;
+
+public class PagedListResponseModel(IEnumerable data, string continuationToken) : ListResponseModel(data)
+ where T : IResponseModel
+{
+ ///
+ /// A cursor for use in pagination.
+ ///
+ public string ContinuationToken { get; set; } = continuationToken;
+}
From 90f308db34a6ce79967f3a93173bd32137125660 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 4 Feb 2025 11:09:09 +0100
Subject: [PATCH 15/22] [deps] Tools: Update aws-sdk-net monorepo (#5278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
src/Core/Core.csproj | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj
index 210a33f3f7..7b319e56c9 100644
--- a/src/Core/Core.csproj
+++ b/src/Core/Core.csproj
@@ -21,8 +21,8 @@
-
-
+
+
From f1b9bd9a099d6d542eb367597c7ef99e9842c233 Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Tue, 4 Feb 2025 09:02:18 -0500
Subject: [PATCH 16/22] [PM-15179] Implement endpoints to add existing
organization to CB provider (#5310)
* Implement endpoints to add existing organization to provider
* Run dotnet format
* Support MOE
* Run dotnet format
* Move ProviderClientsController under AC ownership
* Move ProviderClientsControllerTests under AC ownership
* Jared's feedback
---
.../Billing/ProviderBillingService.cs | 179 ++++++++++++++++++
.../Controllers/ProviderClientsController.cs | 68 ++++++-
.../AddExistingOrganizationRequestBody.cs | 12 ++
.../Repositories/IOrganizationRepository.cs | 2 +
src/Core/Billing/Constants/PlanConstants.cs | 30 +++
src/Core/Billing/Constants/StripeConstants.cs | 10 +
.../Billing/Models/AddableOrganization.cs | 8 +
.../Services/IProviderBillingService.cs | 10 +
src/Core/Constants.cs | 1 +
.../Repositories/OrganizationRepository.cs | 16 ++
.../Repositories/OrganizationRepository.cs | 38 ++++
...nization_ReadAddableToProviderByUserId.sql | 23 +++
.../ProviderClientsControllerTests.cs | 5 +-
...nization_ReadAddableToProviderByUserId.sql | 31 +++
14 files changed, 427 insertions(+), 6 deletions(-)
rename src/Api/{Billing => AdminConsole}/Controllers/ProviderClientsController.cs (67%)
create mode 100644 src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs
create mode 100644 src/Core/Billing/Constants/PlanConstants.cs
create mode 100644 src/Core/Billing/Models/AddableOrganization.cs
create mode 100644 src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql
rename test/Api.Test/{Billing => AdminConsole}/Controllers/ProviderClientsControllerTests.cs (98%)
create mode 100644 util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql
diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs
index 2b834947af..abba8aff90 100644
--- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs
+++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs
@@ -1,12 +1,15 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Models;
+using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
+using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
@@ -24,6 +27,7 @@ using Stripe;
namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService(
+ IEventService eventService,
IGlobalSettings globalSettings,
ILogger logger,
IOrganizationRepository organizationRepository,
@@ -31,10 +35,93 @@ public class ProviderBillingService(
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
+ IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService
{
+ [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
+ public async Task AddExistingOrganization(
+ Provider provider,
+ Organization organization,
+ string key)
+ {
+ await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
+ new SubscriptionUpdateOptions
+ {
+ CancelAtPeriodEnd = false
+ });
+
+ var subscription =
+ await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
+ new SubscriptionCancelOptions
+ {
+ CancellationDetails = new SubscriptionCancellationDetailsOptions
+ {
+ Comment = $"Organization was added to Provider with ID {provider.Id}"
+ },
+ InvoiceNow = true,
+ Prorate = true,
+ Expand = ["latest_invoice", "test_clock"]
+ });
+
+ var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
+
+ var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
+
+ if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
+ {
+ await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
+ new InvoiceFinalizeOptions { AutoAdvance = true });
+ }
+
+ var managedPlanType = await GetManagedPlanTypeAsync(provider, organization);
+
+ // TODO: Replace with PricingClient
+ var plan = StaticStore.GetPlan(managedPlanType);
+ organization.Plan = plan.Name;
+ organization.PlanType = plan.Type;
+ organization.MaxCollections = plan.PasswordManager.MaxCollections;
+ organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
+ organization.UsePolicies = plan.HasPolicies;
+ organization.UseSso = plan.HasSso;
+ organization.UseGroups = plan.HasGroups;
+ organization.UseEvents = plan.HasEvents;
+ organization.UseDirectory = plan.HasDirectory;
+ organization.UseTotp = plan.HasTotp;
+ organization.Use2fa = plan.Has2fa;
+ organization.UseApi = plan.HasApi;
+ organization.UseResetPassword = plan.HasResetPassword;
+ organization.SelfHost = plan.HasSelfHost;
+ organization.UsersGetPremium = plan.UsersGetPremium;
+ organization.UseCustomPermissions = plan.HasCustomPermissions;
+ organization.UseScim = plan.HasScim;
+ organization.UseKeyConnector = plan.HasKeyConnector;
+ organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
+ organization.BillingEmail = provider.BillingEmail!;
+ organization.GatewaySubscriptionId = null;
+ organization.ExpirationDate = null;
+ organization.MaxAutoscaleSeats = null;
+ organization.Status = OrganizationStatusType.Managed;
+
+ var providerOrganization = new ProviderOrganization
+ {
+ ProviderId = provider.Id,
+ OrganizationId = organization.Id,
+ Key = key
+ };
+
+ await Task.WhenAll(
+ organizationRepository.ReplaceAsync(organization),
+ providerOrganizationRepository.CreateAsync(providerOrganization),
+ ScaleSeats(provider, organization.PlanType, organization.Seats!.Value)
+ );
+
+ await eventService.LogProviderOrganizationEventAsync(
+ providerOrganization,
+ EventType.ProviderOrganization_Added);
+ }
+
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
@@ -206,6 +293,81 @@ public class ProviderBillingService(
return memoryStream.ToArray();
}
+ [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
+ public async Task> GetAddableOrganizations(
+ Provider provider,
+ Guid userId)
+ {
+ var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, userId);
+
+ if (providerUser is not { Status: ProviderUserStatusType.Confirmed })
+ {
+ throw new UnauthorizedAccessException();
+ }
+
+ var candidates = await organizationRepository.GetAddableToProviderByUserIdAsync(userId, provider.Type);
+
+ var active = (await Task.WhenAll(candidates.Select(async organization =>
+ {
+ var subscription = await subscriberService.GetSubscription(organization);
+ return (organization, subscription);
+ })))
+ .Where(pair => pair.subscription is
+ {
+ Status:
+ StripeConstants.SubscriptionStatus.Active or
+ StripeConstants.SubscriptionStatus.Trialing or
+ StripeConstants.SubscriptionStatus.PastDue
+ }).ToList();
+
+ if (active.Count == 0)
+ {
+ return [];
+ }
+
+ return await Task.WhenAll(active.Select(async pair =>
+ {
+ var (organization, _) = pair;
+
+ var planName = DerivePlanName(provider, organization);
+
+ var addable = new AddableOrganization(
+ organization.Id,
+ organization.Name,
+ planName,
+ organization.Seats!.Value);
+
+ if (providerUser.Type != ProviderUserType.ServiceUser)
+ {
+ return addable;
+ }
+
+ var applicablePlanType = await GetManagedPlanTypeAsync(provider, organization);
+
+ var requiresPurchase =
+ await SeatAdjustmentResultsInPurchase(provider, applicablePlanType, organization.Seats!.Value);
+
+ return addable with { Disabled = requiresPurchase };
+ }));
+
+ string DerivePlanName(Provider localProvider, Organization localOrganization)
+ {
+ if (localProvider.Type == ProviderType.Msp)
+ {
+ return localOrganization.PlanType switch
+ {
+ var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => "Enterprise",
+ var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => "Teams",
+ _ => throw new BillingException()
+ };
+ }
+
+ // TODO: Replace with PricingClient
+ var plan = StaticStore.GetPlan(localOrganization.PlanType);
+ return plan.Name;
+ }
+ }
+
public async Task ScaleSeats(
Provider provider,
PlanType planType,
@@ -582,4 +744,21 @@ public class ProviderBillingService(
return providerPlan;
}
+
+ private async Task GetManagedPlanTypeAsync(
+ Provider provider,
+ Organization organization)
+ {
+ if (provider.Type == ProviderType.MultiOrganizationEnterprise)
+ {
+ return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
+ }
+
+ return organization.PlanType switch
+ {
+ var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => PlanType.TeamsMonthly,
+ var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => PlanType.EnterpriseMonthly,
+ _ => throw new BillingException()
+ };
+ }
}
diff --git a/src/Api/Billing/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs
similarity index 67%
rename from src/Api/Billing/Controllers/ProviderClientsController.cs
rename to src/Api/AdminConsole/Controllers/ProviderClientsController.cs
index 0c09fa7baf..38d8b254d7 100644
--- a/src/Api/Billing/Controllers/ProviderClientsController.cs
+++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs
@@ -1,4 +1,6 @@
-using Bit.Api.Billing.Models.Requests;
+using Bit.Api.Billing.Controllers;
+using Bit.Api.Billing.Models.Requests;
+using Bit.Core;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
@@ -7,13 +9,15 @@ using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
+using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
-namespace Bit.Api.Billing.Controllers;
+namespace Bit.Api.AdminConsole.Controllers;
[Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController(
ICurrentContext currentContext,
+ IFeatureService featureService,
ILogger logger,
IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService,
@@ -22,7 +26,10 @@ public class ProviderClientsController(
IProviderService providerService,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{
+ private readonly ICurrentContext _currentContext = currentContext;
+
[HttpPost]
+ [SelfHosted(NotSelfHostedOnly = true)]
public async Task CreateAsync(
[FromRoute] Guid providerId,
[FromBody] CreateClientOrganizationRequestBody requestBody)
@@ -80,6 +87,7 @@ public class ProviderClientsController(
}
[HttpPut("{providerOrganizationId:guid}")]
+ [SelfHosted(NotSelfHostedOnly = true)]
public async Task UpdateAsync(
[FromRoute] Guid providerId,
[FromRoute] Guid providerOrganizationId,
@@ -113,7 +121,7 @@ public class ProviderClientsController(
clientOrganization.PlanType,
seatAdjustment);
- if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id))
+ if (seatAdjustmentResultsInPurchase && !_currentContext.ProviderProviderAdmin(provider.Id))
{
return Error.Unauthorized("Service users cannot purchase additional seats.");
}
@@ -127,4 +135,58 @@ public class ProviderClientsController(
return TypedResults.Ok();
}
+
+ [HttpGet("addable")]
+ [SelfHosted(NotSelfHostedOnly = true)]
+ public async Task GetAddableOrganizationsAsync([FromRoute] Guid providerId)
+ {
+ if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
+ {
+ return Error.NotFound();
+ }
+
+ var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
+
+ if (provider == null)
+ {
+ return result;
+ }
+
+ var userId = _currentContext.UserId;
+
+ if (!userId.HasValue)
+ {
+ return Error.Unauthorized();
+ }
+
+ var addable =
+ await providerBillingService.GetAddableOrganizations(provider, userId.Value);
+
+ return TypedResults.Ok(addable);
+ }
+
+ [HttpPost("existing")]
+ [SelfHosted(NotSelfHostedOnly = true)]
+ public async Task AddExistingOrganizationAsync(
+ [FromRoute] Guid providerId,
+ [FromBody] AddExistingOrganizationRequestBody requestBody)
+ {
+ var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
+
+ if (provider == null)
+ {
+ return result;
+ }
+
+ var organization = await organizationRepository.GetByIdAsync(requestBody.OrganizationId);
+
+ if (organization == null)
+ {
+ return Error.BadRequest("The organization being added to the provider does not exist.");
+ }
+
+ await providerBillingService.AddExistingOrganization(provider, organization, requestBody.Key);
+
+ return TypedResults.Ok();
+ }
}
diff --git a/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs
new file mode 100644
index 0000000000..c2add17793
--- /dev/null
+++ b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs
@@ -0,0 +1,12 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bit.Api.Billing.Models.Requests;
+
+public class AddExistingOrganizationRequestBody
+{
+ [Required(ErrorMessage = "'key' must be provided")]
+ public string Key { get; set; }
+
+ [Required(ErrorMessage = "'organizationId' must be provided")]
+ public Guid OrganizationId { get; set; }
+}
diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
index 5b274d3f88..584d95ffe2 100644
--- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
+++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Models.Data.Organizations;
#nullable enable
@@ -22,4 +23,5 @@ public interface IOrganizationRepository : IRepository
/// Gets the organizations that have a verified domain matching the user's email domain.
///
Task> GetByVerifiedUserEmailDomainAsync(Guid userId);
+ Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
}
diff --git a/src/Core/Billing/Constants/PlanConstants.cs b/src/Core/Billing/Constants/PlanConstants.cs
new file mode 100644
index 0000000000..1ac5b8e750
--- /dev/null
+++ b/src/Core/Billing/Constants/PlanConstants.cs
@@ -0,0 +1,30 @@
+using Bit.Core.Billing.Enums;
+
+namespace Bit.Core.Billing.Constants;
+
+public static class PlanConstants
+{
+ public static List EnterprisePlanTypes =>
+ [
+ PlanType.EnterpriseAnnually2019,
+ PlanType.EnterpriseAnnually2020,
+ PlanType.EnterpriseAnnually2023,
+ PlanType.EnterpriseAnnually,
+ PlanType.EnterpriseMonthly2019,
+ PlanType.EnterpriseMonthly2020,
+ PlanType.EnterpriseMonthly2023,
+ PlanType.EnterpriseMonthly
+ ];
+
+ public static List TeamsPlanTypes =>
+ [
+ PlanType.TeamsAnnually2019,
+ PlanType.TeamsAnnually2020,
+ PlanType.TeamsAnnually2023,
+ PlanType.TeamsAnnually,
+ PlanType.TeamsMonthly2019,
+ PlanType.TeamsMonthly2020,
+ PlanType.TeamsMonthly2023,
+ PlanType.TeamsMonthly
+ ];
+}
diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs
index 7371b8b7e9..e3c2b7245e 100644
--- a/src/Core/Billing/Constants/StripeConstants.cs
+++ b/src/Core/Billing/Constants/StripeConstants.cs
@@ -31,6 +31,16 @@ public static class StripeConstants
public const string TaxIdInvalid = "tax_id_invalid";
}
+ public static class InvoiceStatus
+ {
+ public const string Draft = "draft";
+ }
+
+ public static class MetadataKeys
+ {
+ public const string OrganizationId = "organizationId";
+ }
+
public static class PaymentBehavior
{
public const string DefaultIncomplete = "default_incomplete";
diff --git a/src/Core/Billing/Models/AddableOrganization.cs b/src/Core/Billing/Models/AddableOrganization.cs
new file mode 100644
index 0000000000..fe6d5458bd
--- /dev/null
+++ b/src/Core/Billing/Models/AddableOrganization.cs
@@ -0,0 +1,8 @@
+namespace Bit.Core.Billing.Models;
+
+public record AddableOrganization(
+ Guid Id,
+ string Name,
+ string Plan,
+ int Seats,
+ bool Disabled = false);
diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs
index 20e7407628..d6983da03e 100644
--- a/src/Core/Billing/Services/IProviderBillingService.cs
+++ b/src/Core/Billing/Services/IProviderBillingService.cs
@@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
+using Bit.Core.Billing.Models;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Models.Business;
using Stripe;
@@ -10,6 +11,11 @@ namespace Bit.Core.Billing.Services;
public interface IProviderBillingService
{
+ Task AddExistingOrganization(
+ Provider provider,
+ Organization organization,
+ string key);
+
///
/// Changes the assigned provider plan for the provider.
///
@@ -35,6 +41,10 @@ public interface IProviderBillingService
Task GenerateClientInvoiceReport(
string invoiceId);
+ Task> GetAddableOrganizations(
+ Provider provider,
+ Guid userId);
+
///
/// Scales the 's seats for the specified using the provided .
/// This operation may autoscale the provider's Stripe depending on the 's seat minimum for the
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index b196306409..8660010871 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -172,6 +172,7 @@ public static class FeatureFlagKeys
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
+ public const string P15179_AddExistingOrgsFromProviderPortal = "PM-15179-add-existing-orgs-from-provider-portal";
public static List GetAllKeys()
{
diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs
index 20fdf83155..f624f7da28 100644
--- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs
+++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs
@@ -1,5 +1,6 @@
using System.Data;
using Bit.Core.AdminConsole.Entities;
+using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
@@ -180,4 +181,19 @@ public class OrganizationRepository : Repository, IOrganizat
return result.ToList();
}
}
+
+ public async Task> GetAddableToProviderByUserIdAsync(
+ Guid userId,
+ ProviderType providerType)
+ {
+ using (var connection = new SqlConnection(ConnectionString))
+ {
+ var result = await connection.QueryAsync(
+ $"[{Schema}].[{Table}_ReadAddableToProviderByUserId]",
+ new { UserId = userId, ProviderType = providerType },
+ commandType: CommandType.StoredProcedure);
+
+ return result.ToList();
+ }
+ }
}
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
index c1c78eee60..b6ec2ddca0 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
@@ -1,9 +1,12 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
+using Bit.Core.AdminConsole.Enums.Provider;
+using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
+using LinqToDB.Tools;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -298,6 +301,41 @@ public class OrganizationRepository : Repository> GetAddableToProviderByUserIdAsync(
+ Guid userId,
+ ProviderType providerType)
+ {
+ using (var scope = ServiceScopeFactory.CreateScope())
+ {
+ var dbContext = GetDatabaseContext(scope);
+
+ var planTypes = providerType switch
+ {
+ ProviderType.Msp => PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes),
+ ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes,
+ _ => []
+ };
+
+ var query =
+ from organizationUser in dbContext.OrganizationUsers
+ join organization in dbContext.Organizations on organizationUser.OrganizationId equals organization.Id
+ where
+ organizationUser.UserId == userId &&
+ organizationUser.Type == OrganizationUserType.Owner &&
+ organizationUser.Status == OrganizationUserStatusType.Confirmed &&
+ organization.Enabled &&
+ organization.GatewayCustomerId != null &&
+ organization.GatewaySubscriptionId != null &&
+ organization.Seats > 0 &&
+ organization.Status == OrganizationStatusType.Created &&
+ !organization.UseSecretsManager &&
+ organization.PlanType.In(planTypes)
+ select organization;
+
+ return await query.ToArrayAsync();
+ }
+ }
+
public Task EnableCollectionEnhancements(Guid organizationId)
{
throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework.");
diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql
new file mode 100644
index 0000000000..e11109ae10
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql
@@ -0,0 +1,23 @@
+CREATE PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId]
+ @UserId UNIQUEIDENTIFIER,
+ @ProviderType TINYINT
+AS
+BEGIN
+ SET NOCOUNT ON
+ SELECT O.* FROM [dbo].[OrganizationUser] AS OU
+ JOIN [dbo].[Organization] AS O ON O.[Id] = OU.[OrganizationId]
+ WHERE
+ OU.[UserId] = @UserId AND
+ OU.[Type] = 0 AND
+ OU.[Status] = 2 AND
+ O.[Enabled] = 1 AND
+ O.[GatewayCustomerId] IS NOT NULL AND
+ O.[GatewaySubscriptionId] IS NOT NULL AND
+ O.[Seats] > 0 AND
+ O.[Status] = 1 AND
+ O.[UseSecretsManager] = 0 AND
+ -- All Teams & Enterprise for MSP
+ (@ProviderType = 0 AND O.[PlanType] IN (2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20) OR
+ -- All Enterprise for MOE
+ @ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20));
+END
diff --git a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs
similarity index 98%
rename from test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs
rename to test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs
index 86bacd9aa3..8ddd92a5fa 100644
--- a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs
+++ b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs
@@ -1,5 +1,5 @@
using System.Security.Claims;
-using Bit.Api.Billing.Controllers;
+using Bit.Api.AdminConsole.Controllers;
using Bit.Api.Billing.Models.Requests;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
@@ -19,10 +19,9 @@ using Microsoft.AspNetCore.Http.HttpResults;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
-
using static Bit.Api.Test.Billing.Utilities;
-namespace Bit.Api.Test.Billing.Controllers;
+namespace Bit.Api.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(ProviderClientsController))]
[SutProviderCustomize]
diff --git a/util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql b/util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql
new file mode 100644
index 0000000000..1255544d19
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql
@@ -0,0 +1,31 @@
+-- Drop existing SPROC
+IF OBJECT_ID('[dbo].[Organization_ReadAddableToProviderByUserId') IS NOT NULL
+BEGIN
+ DROP PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId]
+END
+GO
+
+CREATE PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId]
+ @UserId UNIQUEIDENTIFIER,
+ @ProviderType TINYINT
+AS
+BEGIN
+ SET NOCOUNT ON
+ SELECT O.* FROM [dbo].[OrganizationUser] AS OU
+ JOIN [dbo].[Organization] AS O ON O.[Id] = OU.[OrganizationId]
+ WHERE
+ OU.[UserId] = @UserId AND
+ OU.[Type] = 0 AND
+ OU.[Status] = 2 AND
+ O.[Enabled] = 1 AND
+ O.[GatewayCustomerId] IS NOT NULL AND
+ O.[GatewaySubscriptionId] IS NOT NULL AND
+ O.[Seats] > 0 AND
+ O.[Status] = 1 AND
+ O.[UseSecretsManager] = 0 AND
+ -- All Teams & Enterprise for MSP
+ (@ProviderType = 0 AND O.[PlanType] IN (2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20) OR
+ -- All Enterprise for MOE
+ @ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20));
+END
+GO
From 3f3da558b6c47a91c09f32e3535451d58f4858ee Mon Sep 17 00:00:00 2001
From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com>
Date: Tue, 4 Feb 2025 08:02:43 -0600
Subject: [PATCH 17/22] [PM-17562] Refactor existing RabbitMq implementation
(#5357)
* [PM-17562] Refactor existing RabbitMq implementation
* Fixed issues noted in PR review
---
.../Services/IEventMessageHandler.cs | 8 +++
.../Implementations/EventRepositoryHandler.cs | 14 ++++
...ostListener.cs => HttpPostEventHandler.cs} | 15 ++---
.../RabbitMqEventRepositoryListener.cs | 29 --------
.../Services/EventLoggingListenerService.cs | 13 ++++
.../RabbitMqEventListenerService.cs} | 27 ++++----
src/Core/Settings/IGlobalSettings.cs | 1 +
src/Events/Startup.cs | 19 +++++-
.../MockedHttpMessageHandler.cs | 3 +
.../Services/EventRepositoryHandlerTests.cs | 24 +++++++
.../Services/HttpPostEventHandlerTests.cs | 66 +++++++++++++++++++
11 files changed, 162 insertions(+), 57 deletions(-)
create mode 100644 src/Core/AdminConsole/Services/IEventMessageHandler.cs
create mode 100644 src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs
rename src/Core/AdminConsole/Services/Implementations/{RabbitMqEventHttpPostListener.cs => HttpPostEventHandler.cs} (52%)
delete mode 100644 src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs
create mode 100644 src/Core/Services/EventLoggingListenerService.cs
rename src/Core/{AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs => Services/Implementations/RabbitMqEventListenerService.cs} (78%)
create mode 100644 test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs
create mode 100644 test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs
diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/AdminConsole/Services/IEventMessageHandler.cs
new file mode 100644
index 0000000000..5df9544c29
--- /dev/null
+++ b/src/Core/AdminConsole/Services/IEventMessageHandler.cs
@@ -0,0 +1,8 @@
+using Bit.Core.Models.Data;
+
+namespace Bit.Core.Services;
+
+public interface IEventMessageHandler
+{
+ Task HandleEventAsync(EventMessage eventMessage);
+}
diff --git a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs
new file mode 100644
index 0000000000..6e4158122c
--- /dev/null
+++ b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs
@@ -0,0 +1,14 @@
+using Bit.Core.Models.Data;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Core.Services;
+
+public class EventRepositoryHandler(
+ [FromKeyedServices("persistent")] IEventWriteService eventWriteService)
+ : IEventMessageHandler
+{
+ public Task HandleEventAsync(EventMessage eventMessage)
+ {
+ return eventWriteService.CreateAsync(eventMessage);
+ }
+}
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs b/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs
similarity index 52%
rename from src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs
rename to src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs
index 5a875f9278..8aece0c1da 100644
--- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventHttpPostListener.cs
+++ b/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs
@@ -1,32 +1,25 @@
using System.Net.Http.Json;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
-using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
-public class RabbitMqEventHttpPostListener : RabbitMqEventListenerBase
+public class HttpPostEventHandler : IEventMessageHandler
{
private readonly HttpClient _httpClient;
private readonly string _httpPostUrl;
- private readonly string _queueName;
- protected override string QueueName => _queueName;
+ public const string HttpClientName = "HttpPostEventHandlerHttpClient";
- public const string HttpClientName = "EventHttpPostListenerHttpClient";
-
- public RabbitMqEventHttpPostListener(
+ public HttpPostEventHandler(
IHttpClientFactory httpClientFactory,
- ILogger logger,
GlobalSettings globalSettings)
- : base(logger, globalSettings)
{
_httpClient = httpClientFactory.CreateClient(HttpClientName);
_httpPostUrl = globalSettings.EventLogging.RabbitMq.HttpPostUrl;
- _queueName = globalSettings.EventLogging.RabbitMq.HttpPostQueueName;
}
- protected override async Task HandleMessageAsync(EventMessage eventMessage)
+ public async Task HandleEventAsync(EventMessage eventMessage)
{
var content = JsonContent.Create(eventMessage);
var response = await _httpClient.PostAsync(_httpPostUrl, content);
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs
deleted file mode 100644
index 25d85bddeb..0000000000
--- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventRepositoryListener.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using Bit.Core.Models.Data;
-using Bit.Core.Settings;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace Bit.Core.Services;
-
-public class RabbitMqEventRepositoryListener : RabbitMqEventListenerBase
-{
- private readonly IEventWriteService _eventWriteService;
- private readonly string _queueName;
-
- protected override string QueueName => _queueName;
-
- public RabbitMqEventRepositoryListener(
- [FromKeyedServices("persistent")] IEventWriteService eventWriteService,
- ILogger logger,
- GlobalSettings globalSettings)
- : base(logger, globalSettings)
- {
- _eventWriteService = eventWriteService;
- _queueName = globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName;
- }
-
- protected override Task HandleMessageAsync(EventMessage eventMessage)
- {
- return _eventWriteService.CreateAsync(eventMessage);
- }
-}
diff --git a/src/Core/Services/EventLoggingListenerService.cs b/src/Core/Services/EventLoggingListenerService.cs
new file mode 100644
index 0000000000..60b8789a6b
--- /dev/null
+++ b/src/Core/Services/EventLoggingListenerService.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.Hosting;
+
+namespace Bit.Core.Services;
+
+public abstract class EventLoggingListenerService : BackgroundService
+{
+ protected readonly IEventMessageHandler _handler;
+
+ protected EventLoggingListenerService(IEventMessageHandler handler)
+ {
+ _handler = handler ?? throw new ArgumentNullException(nameof(handler));
+ }
+}
diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs b/src/Core/Services/Implementations/RabbitMqEventListenerService.cs
similarity index 78%
rename from src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs
rename to src/Core/Services/Implementations/RabbitMqEventListenerService.cs
index 48a549d261..9360170368 100644
--- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerBase.cs
+++ b/src/Core/Services/Implementations/RabbitMqEventListenerService.cs
@@ -1,26 +1,26 @@
using System.Text.Json;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
-using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace Bit.Core.Services;
-public abstract class RabbitMqEventListenerBase : BackgroundService
+public class RabbitMqEventListenerService : EventLoggingListenerService
{
private IChannel _channel;
private IConnection _connection;
private readonly string _exchangeName;
private readonly ConnectionFactory _factory;
- private readonly ILogger _logger;
+ private readonly ILogger _logger;
+ private readonly string _queueName;
- protected abstract string QueueName { get; }
-
- protected RabbitMqEventListenerBase(
- ILogger logger,
- GlobalSettings globalSettings)
+ public RabbitMqEventListenerService(
+ IEventMessageHandler handler,
+ ILogger logger,
+ GlobalSettings globalSettings,
+ string queueName) : base(handler)
{
_factory = new ConnectionFactory
{
@@ -30,6 +30,7 @@ public abstract class RabbitMqEventListenerBase : BackgroundService
};
_exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName;
_logger = logger;
+ _queueName = queueName;
}
public override async Task StartAsync(CancellationToken cancellationToken)
@@ -38,13 +39,13 @@ public abstract class RabbitMqEventListenerBase : BackgroundService
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
await _channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true);
- await _channel.QueueDeclareAsync(queue: QueueName,
+ await _channel.QueueDeclareAsync(queue: _queueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null,
cancellationToken: cancellationToken);
- await _channel.QueueBindAsync(queue: QueueName,
+ await _channel.QueueBindAsync(queue: _queueName,
exchange: _exchangeName,
routingKey: string.Empty,
cancellationToken: cancellationToken);
@@ -59,7 +60,7 @@ public abstract class RabbitMqEventListenerBase : BackgroundService
try
{
var eventMessage = JsonSerializer.Deserialize(eventArgs.Body.Span);
- await HandleMessageAsync(eventMessage);
+ await _handler.HandleEventAsync(eventMessage);
}
catch (Exception ex)
{
@@ -67,7 +68,7 @@ public abstract class RabbitMqEventListenerBase : BackgroundService
}
};
- await _channel.BasicConsumeAsync(QueueName, autoAck: true, consumer: consumer, cancellationToken: stoppingToken);
+ await _channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
@@ -88,6 +89,4 @@ public abstract class RabbitMqEventListenerBase : BackgroundService
_connection.Dispose();
base.Dispose();
}
-
- protected abstract Task HandleMessageAsync(EventMessage eventMessage);
}
diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs
index afe35ed34b..b89df8abf5 100644
--- a/src/Core/Settings/IGlobalSettings.cs
+++ b/src/Core/Settings/IGlobalSettings.cs
@@ -27,4 +27,5 @@ public interface IGlobalSettings
string DatabaseProvider { get; set; }
GlobalSettings.SqlSettings SqlServer { get; set; }
string DevelopmentDirectory { get; set; }
+ GlobalSettings.EventLoggingSettings EventLogging { get; set; }
}
diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs
index 03e99f14e8..b692733a55 100644
--- a/src/Events/Startup.cs
+++ b/src/Events/Startup.cs
@@ -89,13 +89,26 @@ public class Startup
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
{
+ services.AddSingleton();
services.AddKeyedSingleton("persistent");
- services.AddHostedService();
+ services.AddSingleton(provider =>
+ new RabbitMqEventListenerService(
+ provider.GetRequiredService(),
+ provider.GetRequiredService>(),
+ provider.GetRequiredService(),
+ globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName));
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HttpPostUrl))
{
- services.AddHttpClient(RabbitMqEventHttpPostListener.HttpClientName);
- services.AddHostedService();
+ services.AddSingleton();
+ services.AddHttpClient(HttpPostEventHandler.HttpClientName);
+
+ services.AddSingleton(provider =>
+ new RabbitMqEventListenerService(
+ provider.GetRequiredService(),
+ provider.GetRequiredService>(),
+ provider.GetRequiredService(),
+ globalSettings.EventLogging.RabbitMq.HttpPostQueueName));
}
}
}
diff --git a/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs b/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs
index 1b1bd52a03..8a6c1dae97 100644
--- a/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs
+++ b/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs
@@ -8,6 +8,8 @@ public class MockedHttpMessageHandler : HttpMessageHandler
{
private readonly List _matchers = new();
+ public List CapturedRequests { get; } = new List();
+
///
/// The fallback handler to use when the request does not match any of the provided matchers.
///
@@ -16,6 +18,7 @@ public class MockedHttpMessageHandler : HttpMessageHandler
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
+ CapturedRequests.Add(request);
var matcher = _matchers.FirstOrDefault(x => x.Matches(request));
if (matcher == null)
{
diff --git a/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs
new file mode 100644
index 0000000000..2b143f5cb8
--- /dev/null
+++ b/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs
@@ -0,0 +1,24 @@
+using Bit.Core.Models.Data;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Bit.Test.Common.Helpers;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Services;
+
+[SutProviderCustomize]
+public class EventRepositoryHandlerTests
+{
+ [Theory, BitAutoData]
+ public async Task HandleEventAsync_WritesEventToIEventWriteService(
+ EventMessage eventMessage,
+ SutProvider sutProvider)
+ {
+ await sutProvider.Sut.HandleEventAsync(eventMessage);
+ await sutProvider.GetDependency().Received(1).CreateAsync(
+ Arg.Is(AssertHelper.AssertPropertyEqual(eventMessage))
+ );
+ }
+}
diff --git a/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs
new file mode 100644
index 0000000000..414b1c54be
--- /dev/null
+++ b/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs
@@ -0,0 +1,66 @@
+using System.Net;
+using System.Net.Http.Json;
+using Bit.Core.Models.Data;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Bit.Test.Common.Helpers;
+using Bit.Test.Common.MockedHttpClient;
+using NSubstitute;
+using Xunit;
+using GlobalSettings = Bit.Core.Settings.GlobalSettings;
+
+namespace Bit.Core.Test.Services;
+
+[SutProviderCustomize]
+public class HttpPostEventHandlerTests
+{
+ private readonly MockedHttpMessageHandler _handler;
+ private HttpClient _httpClient;
+
+ private const string _httpPostUrl = "http://localhost/test/event";
+
+ public HttpPostEventHandlerTests()
+ {
+ _handler = new MockedHttpMessageHandler();
+ _handler.Fallback
+ .WithStatusCode(HttpStatusCode.OK)
+ .WithContent(new StringContent("testtest"));
+ _httpClient = _handler.ToHttpClient();
+ }
+
+ public SutProvider GetSutProvider()
+ {
+ var clientFactory = Substitute.For();
+ clientFactory.CreateClient(HttpPostEventHandler.HttpClientName).Returns(_httpClient);
+
+ var globalSettings = new GlobalSettings();
+ globalSettings.EventLogging.RabbitMq.HttpPostUrl = _httpPostUrl;
+
+ return new SutProvider()
+ .SetDependency(globalSettings)
+ .SetDependency(clientFactory)
+ .Create();
+ }
+
+ [Theory, BitAutoData]
+ public async Task HandleEventAsync_PostsEventsToUrl(EventMessage eventMessage)
+ {
+ var sutProvider = GetSutProvider();
+ var content = JsonContent.Create(eventMessage);
+
+ await sutProvider.Sut.HandleEventAsync(eventMessage);
+ sutProvider.GetDependency().Received(1).CreateClient(
+ Arg.Is(AssertHelper.AssertPropertyEqual(HttpPostEventHandler.HttpClientName))
+ );
+
+ Assert.Single(_handler.CapturedRequests);
+ var request = _handler.CapturedRequests[0];
+ Assert.NotNull(request);
+ var returned = await request.Content.ReadFromJsonAsync();
+
+ Assert.Equal(HttpMethod.Post, request.Method);
+ Assert.Equal(_httpPostUrl, request.RequestUri.ToString());
+ AssertHelper.AssertPropertyEqual(eventMessage, returned, new[] { "IdempotencyId" });
+ }
+}
From 37b5cef085972c29ffd6e615b7d27a19ffde81ae Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Tue, 4 Feb 2025 09:06:04 -0500
Subject: [PATCH 18/22] [PM-16040] Update
Organization_UnassignedToProviderSearch.sql SPROC to allow Reseller plan
types (#5332)
* Update Organization_UnassignedToProviderSearch.sql SPROC
* Robert's feedback
---
.../Repositories/OrganizationRepository.cs | 18 ++++---
...rganization_UnassignedToProviderSearch.sql | 14 ++---
...rganization_UnassignedToProviderSearch.sql | 54 +++++++++++++++++++
3 files changed, 73 insertions(+), 13 deletions(-)
create mode 100644 util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
index b6ec2ddca0..ea4e1334c6 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
@@ -117,13 +117,19 @@ public class OrganizationRepository : Repository
+ {
+ PlanType.Free,
+ PlanType.Custom,
+ PlanType.FamiliesAnnually2019,
+ PlanType.FamiliesAnnually
+ };
+
var query =
from o in dbContext.Organizations
- where
- ((o.PlanType >= PlanType.TeamsMonthly2019 && o.PlanType <= PlanType.EnterpriseAnnually2019) ||
- (o.PlanType >= PlanType.TeamsMonthly2020 && o.PlanType <= PlanType.EnterpriseAnnually)) &&
- !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) &&
- (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%"))
+ where o.PlanType.NotIn(disallowedPlanTypes) &&
+ !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) &&
+ (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%"))
select o;
if (string.IsNullOrWhiteSpace(ownerEmail))
@@ -155,7 +161,7 @@ public class OrganizationRepository : Repository o.CreationDate).Skip(skip).Take(take).ToArrayAsync();
+ return await query.OrderByDescending(o => o.CreationDate).ThenByDescending(o => o.Id).Skip(skip).Take(take).ToArrayAsync();
}
public async Task UpdateStorageAsync(Guid id)
diff --git a/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql b/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql
index e40f78fee0..4f2269b583 100644
--- a/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql
+++ b/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql
@@ -1,5 +1,5 @@
CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch]
- @Name NVARCHAR(50),
+ @Name NVARCHAR(55),
@OwnerEmail NVARCHAR(256),
@Skip INT = 0,
@Take INT = 25
@@ -9,7 +9,7 @@ BEGIN
SET NOCOUNT ON
DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%'
DECLARE @OwnerLikeSearch NVARCHAR(55) = @OwnerEmail + '%'
-
+
IF @OwnerEmail IS NOT NULL
BEGIN
SELECT
@@ -21,11 +21,11 @@ BEGIN
INNER JOIN
[dbo].[User] U ON U.[Id] = OU.[UserId]
WHERE
- ((O.[PlanType] >= 2 AND O.[PlanType] <= 5) OR (O.[PlanType] >= 8 AND O.[PlanType] <= 20) AND (O.PlanType <> 16)) -- All 'Teams' and 'Enterprise' organizations
+ O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families'
AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
AND (U.[Email] LIKE @OwnerLikeSearch)
- ORDER BY O.[CreationDate] DESC
+ ORDER BY O.[CreationDate] DESC, O.[Id]
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END
@@ -36,11 +36,11 @@ BEGIN
FROM
[dbo].[OrganizationView] O
WHERE
- ((O.[PlanType] >= 2 AND O.[PlanType] <= 5) OR (O.[PlanType] >= 8 AND O.[PlanType] <= 20) AND (O.PlanType <> 16)) -- All 'Teams' and 'Enterprise' organizations
+ O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families'
AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
- ORDER BY O.[CreationDate] DESC
+ ORDER BY O.[CreationDate] DESC, O.[Id]
OFFSET @Skip ROWS
FETCH NEXT @Take ROWS ONLY
END
-END
\ No newline at end of file
+END
diff --git a/util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql b/util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql
new file mode 100644
index 0000000000..07ec9ae8ac
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql
@@ -0,0 +1,54 @@
+-- Drop existing SPROC
+IF OBJECT_ID('[dbo].[Organization_UnassignedToProviderSearch]') IS NOT NULL
+ BEGIN
+ DROP PROCEDURE [dbo].[Organization_UnassignedToProviderSearch]
+ END
+GO
+
+CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch]
+ @Name NVARCHAR(55),
+ @OwnerEmail NVARCHAR(256),
+ @Skip INT = 0,
+ @Take INT = 25
+ WITH RECOMPILE
+AS
+BEGIN
+ SET NOCOUNT ON
+ DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%'
+ DECLARE @OwnerLikeSearch NVARCHAR(55) = @OwnerEmail + '%'
+
+ IF @OwnerEmail IS NOT NULL
+ BEGIN
+ SELECT
+ O.*
+ FROM
+ [dbo].[OrganizationView] O
+ INNER JOIN
+ [dbo].[OrganizationUser] OU ON O.[Id] = OU.[OrganizationId]
+ INNER JOIN
+ [dbo].[User] U ON U.[Id] = OU.[UserId]
+ WHERE
+ O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families'
+ AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
+ AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
+ AND (U.[Email] LIKE @OwnerLikeSearch)
+ ORDER BY O.[CreationDate] DESC, O.[Id]
+ OFFSET @Skip ROWS
+ FETCH NEXT @Take ROWS ONLY
+ END
+ ELSE
+ BEGIN
+ SELECT
+ O.*
+ FROM
+ [dbo].[OrganizationView] O
+ WHERE
+ O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families'
+ AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id])
+ AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch)
+ ORDER BY O.[CreationDate] DESC, O.[Id]
+ OFFSET @Skip ROWS
+ FETCH NEXT @Take ROWS ONLY
+ END
+END
+GO
From 0337300eac108ad2965795758febd895f93781e2 Mon Sep 17 00:00:00 2001
From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
Date: Tue, 4 Feb 2025 15:27:58 +0100
Subject: [PATCH 19/22] [PM-15625]Disable trial/send-verification-email
endpoint for self-host (#5265)
* endpoint is shut off for self-hosted env
Signed-off-by: Cy Okeke
* Fix the reference issues
Signed-off-by: Cy Okeke
---------
Signed-off-by: Cy Okeke
---
src/Identity/Billing/Controller/AccountsController.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/Identity/Billing/Controller/AccountsController.cs b/src/Identity/Billing/Controller/AccountsController.cs
index aada40bcb2..96ec1280cd 100644
--- a/src/Identity/Billing/Controller/AccountsController.cs
+++ b/src/Identity/Billing/Controller/AccountsController.cs
@@ -4,6 +4,7 @@ using Bit.Core.Context;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
+using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Mvc;
@@ -17,6 +18,7 @@ public class AccountsController(
IReferenceEventService referenceEventService) : Microsoft.AspNetCore.Mvc.Controller
{
[HttpPost("trial/send-verification-email")]
+ [SelfHosted(NotSelfHostedOnly = true)]
public async Task PostTrialInitiationSendVerificationEmailAsync([FromBody] TrialSendVerificationEmailRequestModel model)
{
var token = await sendTrialInitiationEmailForRegistrationCommand.Handle(
From b5cfb4b9c73de206999c170519fde76a1fc86682 Mon Sep 17 00:00:00 2001
From: Matt Andreko
Date: Tue, 4 Feb 2025 12:14:55 -0500
Subject: [PATCH 20/22] Enabled SonarQube scanning for PRs (#5363)
* Added scan workflow parameter for PR number to enable branch scanning
* Added missing backslash
---
.github/workflows/scan.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml
index ec2eb7789a..fbcff6b1c0 100644
--- a/.github/workflows/scan.yml
+++ b/.github/workflows/scan.yml
@@ -85,6 +85,7 @@ jobs:
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
+ /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} \
/d:sonar.host.url="https://sonarcloud.io"
dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
From bdbed7adc8ef7be9c6da07207405f2cc04366707 Mon Sep 17 00:00:00 2001
From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Date: Tue, 4 Feb 2025 19:31:15 +0100
Subject: [PATCH 21/22] Group tools owned feature flags (#5362)
Co-authored-by: Daniel James Smith
---
src/Core/Constants.cs | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 8660010871..6f9960919a 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -109,9 +109,15 @@ public static class FeatureFlagKeys
public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
+ /* Tools Team */
+ public const string ItemShare = "item-share";
+ public const string GeneratorToolsModernization = "generator-tools-modernization";
+ public const string MemberAccessReport = "ac-2059-member-access-report";
+ public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
+ public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
+
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
- public const string ItemShare = "item-share";
public const string DuoRedirect = "duo-redirect";
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
@@ -121,7 +127,6 @@ public static class FeatureFlagKeys
public const string RestrictProviderAccess = "restrict-provider-access";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
public const string VaultBulkManagementAction = "vault-bulk-management-action";
- public const string MemberAccessReport = "ac-2059-member-access-report";
public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
public const string TwoFactorComponentRefactor = "two-factor-component-refactor";
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
@@ -146,9 +151,7 @@ public static class FeatureFlagKeys
public const string TrialPayment = "PM-8163-trial-payment";
public const string RemoveServerVersionHeader = "remove-server-version-header";
public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises";
- public const string GeneratorToolsModernization = "generator-tools-modernization";
public const string NewDeviceVerification = "new-device-verification";
- public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
public const string SecurityTasks = "security-tasks";
@@ -170,7 +173,6 @@ public static class FeatureFlagKeys
public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner";
public const string SingleTapPasskeyCreation = "single-tap-passkey-creation";
public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication";
- public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync";
public const string P15179_AddExistingOrgsFromProviderPortal = "PM-15179-add-existing-orgs-from-provider-portal";
From d2fb3760d3da1debd260ba190741ea360644a848 Mon Sep 17 00:00:00 2001
From: Matt Andreko
Date: Tue, 4 Feb 2025 13:53:16 -0500
Subject: [PATCH 22/22] Reworked PR workflow logic to prevent missing parameter
(#5367)
---
.github/workflows/scan.yml | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml
index fbcff6b1c0..1fa5c9587c 100644
--- a/.github/workflows/scan.yml
+++ b/.github/workflows/scan.yml
@@ -85,7 +85,6 @@ jobs:
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
- /d:sonar.pullrequest.key=${{ github.event.pull_request.number }} \
- /d:sonar.host.url="https://sonarcloud.io"
+ /d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"