From aa33a67aeeeea602dbe96a483e1f69a94744fce4 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Fri, 16 Jan 2026 10:33:17 -0500
Subject: [PATCH 1/3] [PM-30858] Fix excessive logs (#6860)
* Add tests showing issue & workaround
- `AddSerilogFileLogging_LegacyConfig_InfoLogs_DoNotFillUpFile` fails
- `AddSerilogFileLogging_LegacyConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile` fails
- `AddSerilogFileLogging_NewConfig_InfoLogs_DoNotFillUpFile` fails
- `AddSerilogFileLogging_NewConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile` works
* Allow customization of LogLevel with legacy path format config
* Lower default logging levels
* Delete tests now that log levels have been customized
---
.../src/Scim/appsettings.Production.json | 6 +-
src/Admin/appsettings.Production.json | 6 +-
src/Api/appsettings.Production.json | 6 +-
src/Core/Utilities/LoggerFactoryExtensions.cs | 38 +++++++-----
src/Events/appsettings.Production.json | 6 +-
.../appsettings.Production.json | 6 +-
src/Icons/appsettings.Production.json | 6 +-
src/Identity/appsettings.Production.json | 6 +-
src/Notifications/appsettings.Production.json | 6 +-
.../Utilities/LoggerFactoryExtensionsTests.cs | 59 ++++++++++++++++++-
10 files changed, 96 insertions(+), 49 deletions(-)
diff --git a/bitwarden_license/src/Scim/appsettings.Production.json b/bitwarden_license/src/Scim/appsettings.Production.json
index d9efbcda12..a6578c08dc 100644
--- a/bitwarden_license/src/Scim/appsettings.Production.json
+++ b/bitwarden_license/src/Scim/appsettings.Production.json
@@ -23,11 +23,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/Admin/appsettings.Production.json b/src/Admin/appsettings.Production.json
index 9f797f3111..1d852abfed 100644
--- a/src/Admin/appsettings.Production.json
+++ b/src/Admin/appsettings.Production.json
@@ -20,11 +20,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/Api/appsettings.Production.json b/src/Api/appsettings.Production.json
index d9efbcda12..a6578c08dc 100644
--- a/src/Api/appsettings.Production.json
+++ b/src/Api/appsettings.Production.json
@@ -23,11 +23,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/Core/Utilities/LoggerFactoryExtensions.cs b/src/Core/Utilities/LoggerFactoryExtensions.cs
index b950e30d5d..f3330f0792 100644
--- a/src/Core/Utilities/LoggerFactoryExtensions.cs
+++ b/src/Core/Utilities/LoggerFactoryExtensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.AspNetCore.Hosting;
+using System.Globalization;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -8,7 +9,7 @@ namespace Bit.Core.Utilities;
public static class LoggerFactoryExtensions
{
///
- ///
+ ///
///
///
///
@@ -21,10 +22,12 @@ public static class LoggerFactoryExtensions
return;
}
+ IConfiguration loggingConfiguration;
+
// If they have begun using the new settings location, use that
if (!string.IsNullOrEmpty(context.Configuration["Logging:PathFormat"]))
{
- logging.AddFile(context.Configuration.GetSection("Logging"));
+ loggingConfiguration = context.Configuration.GetSection("Logging");
}
else
{
@@ -40,28 +43,35 @@ public static class LoggerFactoryExtensions
var projectName = loggingOptions.ProjectName
?? context.HostingEnvironment.ApplicationName;
+ string pathFormat;
+
if (loggingOptions.LogRollBySizeLimit.HasValue)
{
- var pathFormat = loggingOptions.LogDirectoryByProject
+ pathFormat = loggingOptions.LogDirectoryByProject
? Path.Combine(loggingOptions.LogDirectory, projectName, "log.txt")
: Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}.log");
-
- logging.AddFile(
- pathFormat: pathFormat,
- fileSizeLimitBytes: loggingOptions.LogRollBySizeLimit.Value
- );
}
else
{
- var pathFormat = loggingOptions.LogDirectoryByProject
+ pathFormat = loggingOptions.LogDirectoryByProject
? Path.Combine(loggingOptions.LogDirectory, projectName, "{Date}.txt")
: Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}_{{Date}}.log");
-
- logging.AddFile(
- pathFormat: pathFormat
- );
}
+
+ // We want to rely on Serilog using the configuration section to have customization of the log levels
+ // so we make a custom configuration source for them based on the legacy values and allow overrides from
+ // the new location.
+ loggingConfiguration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ {"PathFormat", pathFormat},
+ {"FileSizeLimitBytes", loggingOptions.LogRollBySizeLimit?.ToString(CultureInfo.InvariantCulture)}
+ })
+ .AddConfiguration(context.Configuration.GetSection("Logging"))
+ .Build();
}
+
+ logging.AddFile(loggingConfiguration);
});
}
diff --git a/src/Events/appsettings.Production.json b/src/Events/appsettings.Production.json
index 010f02f8cd..9a10621264 100644
--- a/src/Events/appsettings.Production.json
+++ b/src/Events/appsettings.Production.json
@@ -17,11 +17,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/EventsProcessor/appsettings.Production.json b/src/EventsProcessor/appsettings.Production.json
index 1cce4a9ed3..d57bf98b55 100644
--- a/src/EventsProcessor/appsettings.Production.json
+++ b/src/EventsProcessor/appsettings.Production.json
@@ -1,10 +1,8 @@
{
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/Icons/appsettings.Production.json b/src/Icons/appsettings.Production.json
index 828e8c61cc..19d21f7260 100644
--- a/src/Icons/appsettings.Production.json
+++ b/src/Icons/appsettings.Production.json
@@ -17,11 +17,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/Identity/appsettings.Production.json b/src/Identity/appsettings.Production.json
index 4897a7d8b1..14471b5fb6 100644
--- a/src/Identity/appsettings.Production.json
+++ b/src/Identity/appsettings.Production.json
@@ -20,11 +20,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/src/Notifications/appsettings.Production.json b/src/Notifications/appsettings.Production.json
index 010f02f8cd..735c70e481 100644
--- a/src/Notifications/appsettings.Production.json
+++ b/src/Notifications/appsettings.Production.json
@@ -17,11 +17,9 @@
}
},
"Logging": {
- "IncludeScopes": false,
"LogLevel": {
- "Default": "Debug",
- "System": "Information",
- "Microsoft": "Information"
+ "Default": "Information",
+ "Microsoft": "Warning"
},
"Console": {
"IncludeScopes": true,
diff --git a/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs b/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs
index 81311cb802..ffeb3fa2e7 100644
--- a/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs
+++ b/test/Core.Test/Utilities/LoggerFactoryExtensionsTests.cs
@@ -74,8 +74,7 @@ public class LoggerFactoryExtensionsTests
logger.LogWarning("This is a test");
- // Writing to the file is buffered, give it a little time to flush
- await Task.Delay(5);
+ await provider.DisposeAsync();
var logFile = Assert.Single(tempDir.EnumerateFiles("Logs/*.log"));
@@ -90,13 +89,67 @@ public class LoggerFactoryExtensionsTests
logFileContents
);
}
+
+ [Fact]
+ public async Task AddSerilogFileLogging_LegacyConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile()
+ {
+ await AssertSmallFileAsync((tempDir, config) =>
+ {
+ config["GlobalSettings:LogDirectory"] = tempDir;
+ config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning";
+ });
+ }
+
+ [Fact]
+ public async Task AddSerilogFileLogging_NewConfig_WithLevelCustomization_InfoLogs_DoNotFillUpFile()
+ {
+ await AssertSmallFileAsync((tempDir, config) =>
+ {
+ config["Logging:PathFormat"] = Path.Combine(tempDir, "log.txt");
+ config["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning";
+ });
+ }
+
+ private static async Task AssertSmallFileAsync(Action> configure)
+ {
+ using var tempDir = new TempDirectory();
+ var config = new Dictionary();
+
+ configure(tempDir.Directory, config);
+
+ var provider = GetServiceProvider(config, "Production");
+
+ var loggerFactory = provider.GetRequiredService();
+ var microsoftLogger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Testing");
+
+ for (var i = 0; i < 100; i++)
+ {
+ microsoftLogger.LogInformation("Tons of useless information");
+ }
+
+ var otherLogger = loggerFactory.CreateLogger("Bitwarden");
+
+ for (var i = 0; i < 5; i++)
+ {
+ otherLogger.LogInformation("Mildly more useful information but not as frequent.");
+ }
+
+ await provider.DisposeAsync();
+
+ var logFiles = Directory.EnumerateFiles(tempDir.Directory, "*.txt", SearchOption.AllDirectories);
+ var logFile = Assert.Single(logFiles);
+
+ using var fr = File.OpenRead(logFile);
+ Assert.InRange(fr.Length, 0, 1024);
+ }
+
private static IEnumerable GetProviders(Dictionary initialData, string environment = "Production")
{
var provider = GetServiceProvider(initialData, environment);
return provider.GetServices();
}
- private static IServiceProvider GetServiceProvider(Dictionary initialData, string environment)
+ private static ServiceProvider GetServiceProvider(Dictionary initialData, string environment)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(initialData)
From 8d30fbcc8abe424e604073e721ceaeb365c4fba1 Mon Sep 17 00:00:00 2001
From: Stephon Brown
Date: Fri, 16 Jan 2026 18:13:57 -0500
Subject: [PATCH 2/3] Billing/pm 30882/defect pm coupon removed on upgrade
(#6863)
* fix(billing): update coupon check logic
* tests(billing): update tests and add plan check test
---
.../SubscriptionUpdatedHandler.cs | 11 ++-
.../SubscriptionUpdatedHandlerTests.cs | 89 +++++++++++++++++++
2 files changed, 98 insertions(+), 2 deletions(-)
diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
index c10368d8c0..9e20bd3191 100644
--- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
+++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs
@@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
.PreviousAttributes
.ToObject() as Subscription;
+ // Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the
+ // previous and/or current subscriptions.
+ var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans())
+ .Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null)
+ .Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId)
+ .ToHashSet();
+
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
// changed and unchanged.
var previousSubscriptionHasSecretsManager =
previousSubscription?.Items is not null &&
previousSubscription.Items.Any(
- previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
+ previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id));
var currentSubscriptionHasSecretsManager =
subscription.Items.Any(
- currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId);
+ currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id));
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
{
diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs
index 182f09e163..2259d846b7 100644
--- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs
+++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs
@@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
+using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Test.Billing.Mocks.Plans;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
@@ -654,6 +655,8 @@ public class SubscriptionUpdatedHandlerTests
var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType)
.Returns(plan);
+ _pricingClient.ListPlans()
+ .Returns(MockPlans.Plans);
var parsedEvent = new Event
{
@@ -693,6 +696,92 @@ public class SubscriptionUpdatedHandlerTests
await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId);
await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id);
}
+ [Fact]
+ public async Task
+ HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon()
+ {
+ // Arrange
+ var organizationId = Guid.NewGuid();
+ var subscription = new Subscription
+ {
+ Id = "sub_123",
+ Status = StripeSubscriptionStatus.Active,
+ CustomerId = "cus_123",
+ Items = new StripeList
+ {
+ Data =
+ [
+ new SubscriptionItem
+ {
+ CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
+ Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
+ },
+ new SubscriptionItem
+ {
+ CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
+ Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" }
+ }
+ ]
+ },
+ Customer = new Customer
+ {
+ Balance = 0,
+ Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
+ },
+ Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
+ Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }
+ };
+
+ // Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated
+ var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 };
+
+ var plan = new Teams2023Plan(true);
+ _pricingClient.GetPlanOrThrow(organization.PlanType)
+ .Returns(plan);
+ _pricingClient.ListPlans()
+ .Returns(MockPlans.Plans);
+
+ var parsedEvent = new Event
+ {
+ Data = new EventData
+ {
+ Object = subscription,
+ PreviousAttributes = JObject.FromObject(new
+ {
+ items = new
+ {
+ data = new[]
+ {
+ new { plan = new { id = "secrets-manager-teams-seat-annually" } },
+ }
+ },
+ Items = new StripeList
+ {
+ Data =
+ [
+ new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } },
+ ]
+ }
+ })
+ }
+ };
+
+ _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>())
+ .Returns(subscription);
+
+ _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>())
+ .Returns(Tuple.Create(organizationId, null, null));
+
+ _organizationRepository.GetByIdAsync(organizationId)
+ .Returns(organization);
+
+ // Act
+ await _sut.HandleAsync(parsedEvent);
+
+ // Assert
+ await _stripeFacade.DidNotReceive().DeleteCustomerDiscount(subscription.CustomerId);
+ await _stripeFacade.DidNotReceive().DeleteSubscriptionDiscount(subscription.Id);
+ }
[Theory]
[MemberData(nameof(GetNonActiveSubscriptions))]
From ad19efcff7a4dacb3d538689479867dd780e14eb Mon Sep 17 00:00:00 2001
From: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Date: Sat, 17 Jan 2026 10:47:21 +1000
Subject: [PATCH 3/3] [PM-22236] Fix invited accounts stuck in intermediate
claimed status (#6810)
* Exclude invited users from claimed domain checks.
These users should be excluded by the JOIN on
UserId, but it's a known issue that some invited
users have this FK set.
---
.../Repositories/IOrganizationRepository.cs | 4 +-
.../Repositories/OrganizationRepository.cs | 3 +-
...erReadByClaimedOrganizationDomainsQuery.cs | 2 +
...dByOrganizationIdWithClaimedDomains_V2.sql | 3 +-
...anization_ReadByClaimedUserEmailDomain.sql | 3 +-
.../GetByVerifiedUserEmailDomainAsyncTests.cs | 335 ++++++++++++++++++
.../OrganizationRepositoryTests.cs | 267 +-------------
...rganizationWithClaimedDomainsAsyncTests.cs | 197 ++++++++++
.../OrganizationUserRepositoryTests.cs | 194 ----------
...0_ExcludeInvitedUsersFromClaimedDomain.sql | 24 ++
...cludeInvitedUsersFromClaimedDomains_V2.sql | 29 ++
11 files changed, 606 insertions(+), 455 deletions(-)
create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs
create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs
create mode 100644 util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql
create mode 100644 util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql
diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
index da7a77000b..d79923fdd1 100644
--- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
+++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
@@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository
Task> GetOwnerEmailAddressesById(Guid organizationId);
///
- /// Gets the organizations that have a verified domain matching the user's email domain.
+ /// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user.
+ /// This requires that the organization has claimed the user's domain and the user is an organization member.
+ /// It excludes invited members.
///
Task> GetByVerifiedUserEmailDomainAsync(Guid userId);
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
index 88410facf5..93c8cd304c 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
@@ -325,7 +325,8 @@ public class OrganizationRepository : Repository od.OrganizationId == _organizationId &&
od.VerifiedDate != null &&
diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql
index 64f3d81e08..4f781d2cc9 100644
--- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql
+++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql
@@ -8,13 +8,14 @@ BEGIN
SELECT *
FROM [dbo].[OrganizationUserView]
WHERE [OrganizationId] = @OrganizationId
+ AND [Status] != 0 -- Exclude invited users
),
UserDomains AS (
SELECT U.[Id], U.[EmailDomain]
FROM [dbo].[UserEmailDomainView] U
WHERE EXISTS (
SELECT 1
- FROM [dbo].[OrganizationDomainView] OD
+ FROM [dbo].[OrganizationDomainView] OD
WHERE OD.[OrganizationId] = @OrganizationId
AND OD.[VerifiedDate] IS NOT NULL
AND OD.[DomainName] = U.[EmailDomain]
diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql
index 583f548c8b..ee14c2c52a 100644
--- a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql
+++ b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql
@@ -6,7 +6,7 @@ BEGIN
WITH CTE_User AS (
SELECT
- U.*,
+ U.[Id],
SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain
FROM dbo.[UserView] U
WHERE U.[Id] = @UserId
@@ -19,4 +19,5 @@ BEGIN
WHERE OD.[VerifiedDate] IS NOT NULL
AND CU.EmailDomain = OD.[DomainName]
AND O.[Enabled] = 1
+ AND OU.[Status] != 0 -- Exclude invited users
END
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs
new file mode 100644
index 0000000000..6dd7aafca4
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepository/GetByVerifiedUserEmailDomainAsyncTests.cs
@@ -0,0 +1,335 @@
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationRepository;
+
+public class GetByVerifiedUserEmailDomainAsyncTests
+{
+ [Theory, DatabaseData]
+ public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user1 = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User 1",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var user2 = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User 2",
+ Email = $"test+{id}@x-{domainName}", // Different domain
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var user3 = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User 2",
+ Email = $"test+{id}@{domainName}.example.com", // Different domain
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetVerifiedDate();
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1);
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2);
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3);
+
+ var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id);
+ var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id);
+ var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id);
+
+ Assert.NotEmpty(user1Response);
+ Assert.Equal(organization.Id, user1Response.First().Id);
+ Assert.Empty(user2Response);
+ Assert.Empty(user3Response);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
+
+ var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
+
+ Assert.Empty(result);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization1 = await organizationRepository.CreateTestOrganizationAsync();
+ var organization2 = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain1 = new OrganizationDomain
+ {
+ OrganizationId = organization1.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain1.SetNextRunDate(12);
+ organizationDomain1.SetJobRunCount();
+ organizationDomain1.SetVerifiedDate();
+ await organizationDomainRepository.CreateAsync(organizationDomain1);
+
+ var organizationDomain2 = new OrganizationDomain
+ {
+ OrganizationId = organization2.Id,
+ DomainName = domainName,
+ Txt = "btw+67890",
+ };
+ organizationDomain2.SetNextRunDate(12);
+ organizationDomain2.SetJobRunCount();
+ organizationDomain2.SetVerifiedDate();
+ await organizationDomainRepository.CreateAsync(organizationDomain2);
+
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization1, user);
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization2, user);
+
+ var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, org => org.Id == organization1.Id);
+ Assert.Contains(result, org => org.Id == organization2.Id);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty(
+ IOrganizationRepository organizationRepository)
+ {
+ var nonExistentUserId = Guid.NewGuid();
+
+ var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId);
+
+ Assert.Empty(result);
+ }
+
+ ///
+ /// Tests an edge case where some invited users are created linked to a UserId.
+ /// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly
+ /// exclude such users from the results without relying on the inner join only.
+ /// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused
+ /// any issues to date and we want to minimize edge cases.
+ /// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405
+ ///
+ [Theory, DatabaseData]
+ public async Task GetByVerifiedUserEmailDomainAsync_WithInvitedUserWithUserId_ReturnsEmpty(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetVerifiedDate();
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ // Create invited user with matching email domain but UserId set (edge case)
+ await organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = user.Id,
+ Email = user.Email,
+ Status = OrganizationUserStatusType.Invited,
+ });
+
+ var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
+
+ // Invited users should be excluded even if they have UserId set
+ Assert.Empty(result);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByVerifiedUserEmailDomainAsync_WithAcceptedUser_ReturnsOrganization(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetVerifiedDate();
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
+
+ var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
+
+ Assert.NotEmpty(result);
+ Assert.Equal(organization.Id, result.First().Id);
+ }
+
+ [Theory, DatabaseData]
+ public async Task GetByVerifiedUserEmailDomainAsync_WithRevokedUser_ReturnsOrganization(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetVerifiedDate();
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user);
+
+ var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
+
+ Assert.NotEmpty(result);
+ Assert.Equal(organization.Id, result.First().Id);
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs
index 67e2c1910b..52b1e7484b 100644
--- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs
@@ -8,254 +8,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories;
public class OrganizationRepositoryTests
{
- [DatabaseTheory, DatabaseData]
- public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success(
- IUserRepository userRepository,
- IOrganizationRepository organizationRepository,
- IOrganizationUserRepository organizationUserRepository,
- IOrganizationDomainRepository organizationDomainRepository)
- {
- var id = Guid.NewGuid();
- var domainName = $"{id}.example.com";
-
- var user1 = await userRepository.CreateAsync(new User
- {
- Name = "Test User 1",
- Email = $"test+{id}@{domainName}",
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var user2 = await userRepository.CreateAsync(new User
- {
- Name = "Test User 2",
- Email = $"test+{id}@x-{domainName}", // Different domain
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var user3 = await userRepository.CreateAsync(new User
- {
- Name = "Test User 2",
- Email = $"test+{id}@{domainName}.example.com", // Different domain
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var organization = await organizationRepository.CreateAsync(new Organization
- {
- Name = $"Test Org {id}",
- BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
- Plan = "Test", // TODO: EF does not enforce this being NOT NULL
- PrivateKey = "privatekey",
- });
-
- var organizationDomain = new OrganizationDomain
- {
- OrganizationId = organization.Id,
- DomainName = domainName,
- Txt = "btw+12345",
- };
- organizationDomain.SetVerifiedDate();
- organizationDomain.SetNextRunDate(12);
- organizationDomain.SetJobRunCount();
- await organizationDomainRepository.CreateAsync(organizationDomain);
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- OrganizationId = organization.Id,
- UserId = user1.Id,
- Status = OrganizationUserStatusType.Confirmed,
- ResetPasswordKey = "resetpasswordkey1",
- });
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- OrganizationId = organization.Id,
- UserId = user2.Id,
- Status = OrganizationUserStatusType.Confirmed,
- ResetPasswordKey = "resetpasswordkey1",
- });
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- OrganizationId = organization.Id,
- UserId = user3.Id,
- Status = OrganizationUserStatusType.Confirmed,
- ResetPasswordKey = "resetpasswordkey1",
- });
-
- var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id);
- var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id);
- var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id);
-
- Assert.NotEmpty(user1Response);
- Assert.Equal(organization.Id, user1Response.First().Id);
- Assert.Empty(user2Response);
- Assert.Empty(user3Response);
- }
-
- [DatabaseTheory, DatabaseData]
- public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty(
- IUserRepository userRepository,
- IOrganizationRepository organizationRepository,
- IOrganizationUserRepository organizationUserRepository,
- IOrganizationDomainRepository organizationDomainRepository)
- {
- var id = Guid.NewGuid();
- var domainName = $"{id}.example.com";
-
- var user = await userRepository.CreateAsync(new User
- {
- Name = "Test User",
- Email = $"test+{id}@{domainName}",
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var organization = await organizationRepository.CreateAsync(new Organization
- {
- Name = $"Test Org {id}",
- BillingEmail = user.Email,
- Plan = "Test",
- PrivateKey = "privatekey",
- });
-
- var organizationDomain = new OrganizationDomain
- {
- OrganizationId = organization.Id,
- DomainName = domainName,
- Txt = "btw+12345",
- };
- organizationDomain.SetNextRunDate(12);
- organizationDomain.SetJobRunCount();
- await organizationDomainRepository.CreateAsync(organizationDomain);
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- OrganizationId = organization.Id,
- UserId = user.Id,
- Status = OrganizationUserStatusType.Confirmed,
- ResetPasswordKey = "resetpasswordkey",
- });
-
- var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
-
- Assert.Empty(result);
- }
-
- [DatabaseTheory, DatabaseData]
- public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations(
- IUserRepository userRepository,
- IOrganizationRepository organizationRepository,
- IOrganizationUserRepository organizationUserRepository,
- IOrganizationDomainRepository organizationDomainRepository)
- {
- var id = Guid.NewGuid();
- var domainName = $"{id}.example.com";
-
- var user = await userRepository.CreateAsync(new User
- {
- Name = "Test User",
- Email = $"test+{id}@{domainName}",
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var organization1 = await organizationRepository.CreateAsync(new Organization
- {
- Name = $"Test Org 1 {id}",
- BillingEmail = user.Email,
- Plan = "Test",
- PrivateKey = "privatekey1",
- });
-
- var organization2 = await organizationRepository.CreateAsync(new Organization
- {
- Name = $"Test Org 2 {id}",
- BillingEmail = user.Email,
- Plan = "Test",
- PrivateKey = "privatekey2",
- });
-
- var organizationDomain1 = new OrganizationDomain
- {
- OrganizationId = organization1.Id,
- DomainName = domainName,
- Txt = "btw+12345",
- };
- organizationDomain1.SetNextRunDate(12);
- organizationDomain1.SetJobRunCount();
- organizationDomain1.SetVerifiedDate();
- await organizationDomainRepository.CreateAsync(organizationDomain1);
-
- var organizationDomain2 = new OrganizationDomain
- {
- OrganizationId = organization2.Id,
- DomainName = domainName,
- Txt = "btw+67890",
- };
- organizationDomain2.SetNextRunDate(12);
- organizationDomain2.SetJobRunCount();
- organizationDomain2.SetVerifiedDate();
- await organizationDomainRepository.CreateAsync(organizationDomain2);
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- OrganizationId = organization1.Id,
- UserId = user.Id,
- Status = OrganizationUserStatusType.Confirmed,
- ResetPasswordKey = "resetpasswordkey1",
- });
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- OrganizationId = organization2.Id,
- UserId = user.Id,
- Status = OrganizationUserStatusType.Confirmed,
- ResetPasswordKey = "resetpasswordkey2",
- });
-
- var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);
-
- Assert.Equal(2, result.Count);
- Assert.Contains(result, org => org.Id == organization1.Id);
- Assert.Contains(result, org => org.Id == organization2.Id);
- }
-
- [DatabaseTheory, DatabaseData]
- public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty(
- IOrganizationRepository organizationRepository)
- {
- var nonExistentUserId = Guid.NewGuid();
-
- var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId);
-
- Assert.Empty(result);
- }
-
-
- [DatabaseTheory, DatabaseData]
+ [Theory, DatabaseData]
public async Task GetManyByIdsAsync_ExistingOrganizations_ReturnsOrganizations(IOrganizationRepository organizationRepository)
{
var email = "test@email.com";
@@ -287,7 +40,7 @@ public class OrganizationRepositoryTests
await organizationRepository.DeleteAsync(organization2);
}
- [DatabaseTheory, DatabaseData]
+ [Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithUsersAndSponsorships_ReturnsCorrectCounts(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
@@ -356,7 +109,7 @@ public class OrganizationRepositoryTests
Assert.Equal(4, result.Total); // Total occupied seats
}
- [DatabaseTheory, DatabaseData]
+ [Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithNoUsersOrSponsorships_ReturnsZero(
IOrganizationRepository organizationRepository)
{
@@ -372,7 +125,7 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Total);
}
- [DatabaseTheory, DatabaseData]
+ [Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyRevokedUsers_ReturnsZero(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
@@ -399,7 +152,7 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Total);
}
- [DatabaseTheory, DatabaseData]
+ [Theory, DatabaseData]
public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyExpiredSponsorships_ReturnsZero(
IOrganizationRepository organizationRepository,
IOrganizationSponsorshipRepository organizationSponsorshipRepository)
@@ -424,7 +177,7 @@ public class OrganizationRepositoryTests
Assert.Equal(0, result.Total);
}
- [DatabaseTheory, DatabaseData]
+ [Theory, DatabaseData]
public async Task IncrementSeatCountAsync_IncrementsSeatCount(IOrganizationRepository organizationRepository)
{
var organization = await organizationRepository.CreateTestOrganizationAsync();
@@ -438,7 +191,7 @@ public class OrganizationRepositoryTests
Assert.Equal(8, result.Seats);
}
- [DatabaseData, DatabaseTheory]
+ [DatabaseData, Theory]
public async Task IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
IOrganizationRepository sutRepository)
{
@@ -462,7 +215,7 @@ public class OrganizationRepositoryTests
await sutRepository.DeleteAsync(organization);
}
- [DatabaseData, DatabaseTheory]
+ [DatabaseData, Theory]
public async Task IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(
IOrganizationRepository sutRepository)
{
@@ -487,7 +240,7 @@ public class OrganizationRepositoryTests
await sutRepository.DeleteAsync(organization);
}
- [DatabaseData, DatabaseTheory]
+ [DatabaseData, Theory]
public async Task GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate(
IOrganizationRepository sutRepository)
{
@@ -510,7 +263,7 @@ public class OrganizationRepositoryTests
await sutRepository.DeleteAsync(organization);
}
- [DatabaseData, DatabaseTheory]
+ [DatabaseData, Theory]
public async Task UpdateSuccessfulOrganizationSyncStatusAsync_GivenOrganizationHasChangedSeatCount_WhenUpdatingStatus_ThenSuccessfullyUpdatesOrgSoItDoesntSync(
IOrganizationRepository sutRepository)
{
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs
new file mode 100644
index 0000000000..6fa395751b
--- /dev/null
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetManyByOrganizationWithClaimedDomainsAsyncTests.cs
@@ -0,0 +1,197 @@
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Xunit;
+
+namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository;
+
+public class GetManyByOrganizationWithClaimedDomainsAsyncTests
+{
+ [Theory, DatabaseData]
+ public async Task WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user1 = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User 1",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var user2 = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User 2",
+ Email = $"test+{id}@x-{domainName}", // Different domain
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var user3 = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User 3",
+ Email = $"test+{id}@{domainName}.example.com", // Different domain
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetVerifiedDate();
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ var orgUser1 = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1);
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2);
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3);
+
+ var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
+
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal(orgUser1.Id, result.Single().Id);
+ }
+
+ [Theory, DatabaseData]
+ public async Task WithNoVerifiedDomain_ReturnsEmpty(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var user = await userRepository.CreateAsync(new User
+ {
+ Name = "Test User",
+ Email = $"test+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ // Create domain but do NOT verify it
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetNextRunDate(12);
+ // Note: NOT calling SetVerifiedDate()
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
+
+ var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
+
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ ///
+ /// Tests an edge case where some invited users are created linked to a UserId.
+ /// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly
+ /// exclude such users from the results without relying on the inner join only.
+ /// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused
+ /// any issues to date and we want to minimize edge cases.
+ /// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405
+ ///
+ [Theory, DatabaseData]
+ public async Task WithVerifiedDomain_ExcludesInvitedUsers(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationDomainRepository organizationDomainRepository)
+ {
+ var id = Guid.NewGuid();
+ var domainName = $"{id}.example.com";
+
+ var invitedUser = await userRepository.CreateAsync(new User
+ {
+ Name = "Invited User",
+ Email = $"invited+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var confirmedUser = await userRepository.CreateAsync(new User
+ {
+ Name = "Confirmed User",
+ Email = $"confirmed+{id}@{domainName}",
+ ApiKey = "TEST",
+ SecurityStamp = "stamp",
+ Kdf = KdfType.PBKDF2_SHA256,
+ KdfIterations = 1,
+ KdfMemory = 2,
+ KdfParallelism = 3
+ });
+
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var organizationDomain = new OrganizationDomain
+ {
+ OrganizationId = organization.Id,
+ DomainName = domainName,
+ Txt = "btw+12345",
+ };
+ organizationDomain.SetVerifiedDate();
+ organizationDomain.SetNextRunDate(12);
+ organizationDomain.SetJobRunCount();
+ await organizationDomainRepository.CreateAsync(organizationDomain);
+
+ // Create invited user with UserId set (edge case - should be excluded even with UserId linked)
+ var invitedOrgUser = await organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = invitedUser.Id, // Edge case: invited user with UserId set
+ Email = invitedUser.Email,
+ Status = OrganizationUserStatusType.Invited,
+ Type = OrganizationUserType.User
+ });
+
+ // Create confirmed user linked by UserId only (no Email field set)
+ var confirmedOrgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, confirmedUser);
+
+ var result = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
+
+ Assert.NotNull(result);
+ var claimedUser = Assert.Single(result);
+ Assert.Equal(confirmedOrgUser.Id, claimedUser.Id);
+ }
+}
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs
index 1c433d0e6e..b77406abf5 100644
--- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs
@@ -599,136 +599,6 @@ public class OrganizationUserRepositoryTests
Assert.Null(orgWithoutSsoDetails.SsoConfig);
}
- [DatabaseTheory, DatabaseData]
- public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle(
- IUserRepository userRepository,
- IOrganizationRepository organizationRepository,
- IOrganizationUserRepository organizationUserRepository,
- IOrganizationDomainRepository organizationDomainRepository)
- {
- var id = Guid.NewGuid();
- var domainName = $"{id}.example.com";
-
- var user1 = await userRepository.CreateAsync(new User
- {
- Name = "Test User 1",
- Email = $"test+{id}@{domainName}",
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var user2 = await userRepository.CreateAsync(new User
- {
- Name = "Test User 2",
- Email = $"test+{id}@x-{domainName}", // Different domain
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var user3 = await userRepository.CreateAsync(new User
- {
- Name = "Test User 2",
- Email = $"test+{id}@{domainName}.example.com", // Different domain
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- Kdf = KdfType.PBKDF2_SHA256,
- KdfIterations = 1,
- KdfMemory = 2,
- KdfParallelism = 3
- });
-
- var organization = await organizationRepository.CreateAsync(new Organization
- {
- Name = $"Test Org {id}",
- BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
- Plan = "Test", // TODO: EF does not enforce this being NOT NULL
- PrivateKey = "privatekey",
- UsePolicies = false,
- UseSso = false,
- UseKeyConnector = false,
- UseScim = false,
- UseGroups = false,
- UseDirectory = false,
- UseEvents = false,
- UseTotp = false,
- Use2fa = false,
- UseApi = false,
- UseResetPassword = false,
- UseSecretsManager = false,
- SelfHost = false,
- UsersGetPremium = false,
- UseCustomPermissions = false,
- Enabled = true,
- UsePasswordManager = false,
- LimitCollectionCreation = false,
- LimitCollectionDeletion = false,
- LimitItemDeletion = false,
- AllowAdminAccessToAllCollectionItems = false,
- UseRiskInsights = false,
- UseAdminSponsoredFamilies = false,
- UsePhishingBlocker = false,
- UseDisableSmAdsForUsers = false,
- });
-
- var organizationDomain = new OrganizationDomain
- {
- OrganizationId = organization.Id,
- DomainName = domainName,
- Txt = "btw+12345",
- };
- organizationDomain.SetVerifiedDate();
- organizationDomain.SetNextRunDate(12);
- organizationDomain.SetJobRunCount();
- await organizationDomainRepository.CreateAsync(organizationDomain);
-
- var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- Id = CoreHelpers.GenerateComb(),
- OrganizationId = organization.Id,
- UserId = user1.Id,
- Status = OrganizationUserStatusType.Confirmed,
- Type = OrganizationUserType.Owner,
- ResetPasswordKey = "resetpasswordkey1",
- AccessSecretsManager = false
- });
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- Id = CoreHelpers.GenerateComb(),
- OrganizationId = organization.Id,
- UserId = user2.Id,
- Status = OrganizationUserStatusType.Confirmed,
- Type = OrganizationUserType.User,
- ResetPasswordKey = "resetpasswordkey1",
- AccessSecretsManager = false
- });
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- Id = CoreHelpers.GenerateComb(),
- OrganizationId = organization.Id,
- UserId = user3.Id,
- Status = OrganizationUserStatusType.Confirmed,
- Type = OrganizationUserType.User,
- ResetPasswordKey = "resetpasswordkey1",
- AccessSecretsManager = false
- });
-
- var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
-
- Assert.NotNull(responseModel);
- Assert.Single(responseModel);
- Assert.Equal(orgUser1.Id, responseModel.Single().Id);
- }
-
[DatabaseTheory, DatabaseData]
public async Task CreateManyAsync_NoId_Works(IOrganizationRepository organizationRepository,
IUserRepository userRepository,
@@ -1237,70 +1107,6 @@ public class OrganizationUserRepositoryTests
Assert.DoesNotContain(user1Result.Collections, c => c.Id == defaultUserCollection.Id);
}
- [DatabaseTheory, DatabaseData]
- public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithNoVerifiedDomain_ReturnsEmpty(
- IUserRepository userRepository,
- IOrganizationRepository organizationRepository,
- IOrganizationUserRepository organizationUserRepository,
- IOrganizationDomainRepository organizationDomainRepository)
- {
- var id = Guid.NewGuid();
- var domainName = $"{id}.example.com";
- var requestTime = DateTime.UtcNow;
-
- var user1 = await userRepository.CreateAsync(new User
- {
- Id = CoreHelpers.GenerateComb(),
- Name = "Test User 1",
- Email = $"test+{id}@{domainName}",
- ApiKey = "TEST",
- SecurityStamp = "stamp",
- CreationDate = requestTime,
- RevisionDate = requestTime,
- AccountRevisionDate = requestTime
- });
-
- var organization = await organizationRepository.CreateAsync(new Organization
- {
- Id = CoreHelpers.GenerateComb(),
- Name = $"Test Org {id}",
- BillingEmail = user1.Email,
- Plan = "Test",
- Enabled = true,
- CreationDate = requestTime,
- RevisionDate = requestTime
- });
-
- // Create domain but do NOT verify it
- var organizationDomain = new OrganizationDomain
- {
- Id = CoreHelpers.GenerateComb(),
- OrganizationId = organization.Id,
- DomainName = domainName,
- Txt = "btw+12345",
- CreationDate = requestTime
- };
- organizationDomain.SetNextRunDate(12);
- // Note: NOT calling SetVerifiedDate()
- await organizationDomainRepository.CreateAsync(organizationDomain);
-
- await organizationUserRepository.CreateAsync(new OrganizationUser
- {
- Id = CoreHelpers.GenerateComb(),
- OrganizationId = organization.Id,
- UserId = user1.Id,
- Status = OrganizationUserStatusType.Confirmed,
- Type = OrganizationUserType.Owner,
- CreationDate = requestTime,
- RevisionDate = requestTime
- });
-
- var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id);
-
- Assert.NotNull(responseModel);
- Assert.Empty(responseModel);
- }
-
[DatabaseTheory, DatabaseData]
public async Task DeleteAsync_WithNullEmail_DoesNotSetDefaultUserCollectionEmail(IUserRepository userRepository,
ICollectionRepository collectionRepository,
diff --git a/util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql b/util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql
new file mode 100644
index 0000000000..788fa02b7c
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-01-14_00_ExcludeInvitedUsersFromClaimedDomain.sql
@@ -0,0 +1,24 @@
+CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadByClaimedUserEmailDomain]
+ @UserId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON;
+
+ WITH CTE_User AS (
+ SELECT
+ U.[Id],
+ SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain
+ FROM dbo.[UserView] U
+ WHERE U.[Id] = @UserId
+ )
+ SELECT O.*
+ FROM CTE_User CU
+ INNER JOIN dbo.[OrganizationUserView] OU ON CU.[Id] = OU.[UserId]
+ INNER JOIN dbo.[OrganizationView] O ON OU.[OrganizationId] = O.[Id]
+ INNER JOIN dbo.[OrganizationDomainView] OD ON OU.[OrganizationId] = OD.[OrganizationId]
+ WHERE OD.[VerifiedDate] IS NOT NULL
+ AND CU.EmailDomain = OD.[DomainName]
+ AND O.[Enabled] = 1
+ AND OU.[Status] != 0 -- Exclude invited users
+END
+GO
diff --git a/util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql b/util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql
new file mode 100644
index 0000000000..b7be5fd0e0
--- /dev/null
+++ b/util/Migrator/DbScripts/2026-01-14_01_ExcludeInvitedUsersFromClaimedDomains_V2.sql
@@ -0,0 +1,29 @@
+CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]
+ @OrganizationId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON;
+
+ WITH OrgUsers AS (
+ SELECT *
+ FROM [dbo].[OrganizationUserView]
+ WHERE [OrganizationId] = @OrganizationId
+ AND [Status] != 0 -- Exclude invited users
+ ),
+ UserDomains AS (
+ SELECT U.[Id], U.[EmailDomain]
+ FROM [dbo].[UserEmailDomainView] U
+ WHERE EXISTS (
+ SELECT 1
+ FROM [dbo].[OrganizationDomainView] OD
+ WHERE OD.[OrganizationId] = @OrganizationId
+ AND OD.[VerifiedDate] IS NOT NULL
+ AND OD.[DomainName] = U.[EmailDomain]
+ )
+ )
+ SELECT OU.*
+ FROM OrgUsers OU
+ JOIN UserDomains UD ON OU.[UserId] = UD.[Id]
+ OPTION (RECOMPILE);
+END
+GO