diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs
index 00bac01f7609..00ba706a415f 100644
--- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs
+++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs
@@ -20,6 +20,12 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I
public string Email { get; set; }
public string AvatarColor { get; set; }
public string TwoFactorProviders { get; set; }
+ ///
+ /// Indicates whether the user has a personal premium subscription.
+ /// Does not include premium access from organizations -
+ /// do not use this to check whether the user can access premium features.
+ /// Null when the organization user is in Invited status (UserId is null).
+ ///
public bool? Premium { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs
index 697c10690cd9..406b39cd69c2 100644
--- a/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/Interfaces/ITwoFactorIsEnabledQuery.cs
@@ -1,4 +1,5 @@
using Bit.Core.Auth.Models;
+using Bit.Core.Entities;
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
@@ -22,4 +23,25 @@ public interface ITwoFactorIsEnabledQuery
///
/// The user to check.
Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user);
+
+ ///
+ /// Returns a list of user IDs and whether two factor is enabled for each user.
+ /// This version uses HasPremiumAccessQuery with cached organization abilities for better performance.
+ ///
+ /// The list of user IDs to check.
+ Task> TwoFactorIsEnabledVNextAsync(IEnumerable userIds);
+ ///
+ /// Returns a list of users and whether two factor is enabled for each user.
+ /// This version uses HasPremiumAccessQuery with cached organization abilities for better performance.
+ ///
+ /// The list of users to check.
+ /// The type of user in the list. Must implement .
+ Task> TwoFactorIsEnabledVNextAsync(IEnumerable users) where T : ITwoFactorProvidersUser;
+ ///
+ /// Returns whether two factor is enabled for the user. A user is able to have a TwoFactorProvider that is enabled but requires Premium.
+ /// If the user does not have premium then the TwoFactorProvider is considered _not_ enabled.
+ /// This version uses HasPremiumAccessQuery with cached organization abilities for better performance.
+ ///
+ /// The user to check.
+ Task TwoFactorIsEnabledVNextAsync(User user);
}
diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
index cc86d3d71d39..de8df635a2f5 100644
--- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
+++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
@@ -4,13 +4,24 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
+using Bit.Core.Billing.Premium.Queries;
+using Bit.Core.Entities;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;
-public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFactorIsEnabledQuery
+public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery
{
- private readonly IUserRepository _userRepository = userRepository;
+ private readonly IUserRepository _userRepository;
+ private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery;
+
+ public TwoFactorIsEnabledQuery(
+ IUserRepository userRepository,
+ IHasPremiumAccessQuery hasPremiumAccessQuery)
+ {
+ _userRepository = userRepository;
+ _hasPremiumAccessQuery = hasPremiumAccessQuery;
+ }
public async Task> TwoFactorIsEnabledAsync(IEnumerable userIds)
{
@@ -72,12 +83,113 @@ public async Task TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user)
}
return await TwoFactorEnabledAsync(
- user.GetTwoFactorProviders(),
- async () =>
- {
- var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value);
- return calcUser?.HasPremiumAccess ?? false;
- });
+ user.GetTwoFactorProviders(),
+ async () =>
+ {
+ var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value);
+ return calcUser?.HasPremiumAccess ?? false;
+ });
+ }
+
+ public async Task> TwoFactorIsEnabledVNextAsync(IEnumerable userIds)
+ {
+ var result = new List<(Guid userId, bool hasTwoFactor)>();
+ if (userIds == null || !userIds.Any())
+ {
+ return result;
+ }
+
+ var users = await _userRepository.GetManyAsync([.. userIds]);
+ var premiumStatus = await _hasPremiumAccessQuery.HasPremiumAccessAsync(userIds);
+
+ foreach (var user in users)
+ {
+ var twoFactorProviders = user.GetTwoFactorProviders();
+ var hasPremiumAccess = premiumStatus.GetValueOrDefault(user.Id, false);
+ var twoFactorIsEnabled = TwoFactorIsEnabled(twoFactorProviders, hasPremiumAccess);
+
+ result.Add((user.Id, twoFactorIsEnabled));
+ }
+
+ return result;
+ }
+
+ public async Task> TwoFactorIsEnabledVNextAsync(IEnumerable users)
+ where T : ITwoFactorProvidersUser
+ {
+ var userIds = users
+ .Select(u => u.GetUserId())
+ .Where(u => u.HasValue)
+ .Select(u => u.Value)
+ .ToList();
+
+ var twoFactorResults = await TwoFactorIsEnabledVNextAsync(userIds);
+
+ var result = new List<(T user, bool twoFactorIsEnabled)>();
+
+ foreach (var user in users)
+ {
+ var userId = user.GetUserId();
+ if (userId.HasValue)
+ {
+ var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled;
+ result.Add((user, hasTwoFactor));
+ }
+ else
+ {
+ result.Add((user, false));
+ }
+ }
+
+ return result;
+ }
+
+ public async Task TwoFactorIsEnabledVNextAsync(User user)
+ {
+ var providers = user.GetTwoFactorProviders();
+ var hasPremium = await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id);
+
+ return TwoFactorIsEnabled(providers, hasPremium);
+ }
+
+ ///
+ /// Checks to see what kind of two-factor is enabled.
+ /// Synchronous version used when premium access status is already known.
+ ///
+ /// dictionary of two factor providers
+ /// whether the user has premium access
+ /// true if the user has two factor enabled; false otherwise
+ private static bool TwoFactorIsEnabled(
+ Dictionary providers,
+ bool hasPremiumAccess)
+ {
+ // If there are no providers, then two factor is not enabled
+ if (providers == null || providers.Count == 0)
+ {
+ return false;
+ }
+
+ // Get all enabled providers
+ // TODO: PM-21210: In practice we don't save disabled providers to the database, worth looking into.
+ var enabledProviderKeys = from provider in providers
+ where provider.Value?.Enabled ?? false
+ select provider.Key;
+
+ // If no providers are enabled then two factor is not enabled
+ if (!enabledProviderKeys.Any())
+ {
+ return false;
+ }
+
+ // If there are only premium two factor options then check premium access
+ var onlyHasPremiumTwoFactor = enabledProviderKeys.All(TwoFactorProvider.RequiresPremium);
+ if (onlyHasPremiumTwoFactor)
+ {
+ return hasPremiumAccess;
+ }
+
+ // The user has at least one non-premium two factor option
+ return true;
}
///
diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
index 7c50f7f17be3..07669c623882 100644
--- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
+++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
@@ -9,6 +9,7 @@
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
+using Bit.Core.Billing.Premium.Queries;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.KeyManagement.UserKey.Implementations;
using Bit.Core.Services;
@@ -27,6 +28,7 @@ public static void AddUserServices(this IServiceCollection services, IGlobalSett
services.AddUserRegistrationCommands();
services.AddWebAuthnLoginCommands();
services.AddTdeOffboardingPasswordCommands();
+ services.AddPremiumAccessQueries();
services.AddTwoFactorQueries();
services.AddSsoQueries();
}
@@ -65,6 +67,11 @@ private static void AddWebAuthnLoginCommands(this IServiceCollection services)
services.AddScoped();
}
+ private static void AddPremiumAccessQueries(this IServiceCollection services)
+ {
+ services.AddScoped();
+ }
+
private static void AddTwoFactorQueries(this IServiceCollection services)
{
services.AddScoped();
diff --git a/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs
new file mode 100644
index 000000000000..c047012380eb
--- /dev/null
+++ b/src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs
@@ -0,0 +1,44 @@
+using Bit.Core.Repositories;
+
+namespace Bit.Core.Billing.Premium.Queries;
+
+///
+/// Query for checking premium access status for users using the existing stored procedure
+/// that calculates premium access from personal subscriptions and organization memberships.
+///
+public class HasPremiumAccessQuery : IHasPremiumAccessQuery
+{
+ private readonly IUserRepository _userRepository;
+
+ public HasPremiumAccessQuery(IUserRepository userRepository)
+ {
+ _userRepository = userRepository;
+ }
+
+ public async Task HasPremiumAccessAsync(Guid userId)
+ {
+ var user = await _userRepository.GetCalculatedPremiumAsync(userId);
+ return user?.HasPremiumAccess ?? false;
+ }
+
+ public async Task> HasPremiumAccessAsync(IEnumerable userIds)
+ {
+ var usersWithPremium = await _userRepository.GetManyWithCalculatedPremiumAsync(userIds);
+ return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess);
+ }
+
+ public async Task HasPremiumFromOrganizationAsync(Guid userId)
+ {
+ var user = await _userRepository.GetCalculatedPremiumAsync(userId);
+ if (user == null)
+ {
+ return false;
+ }
+
+ // Has org premium if has premium access but not personal premium
+ return user.HasPremiumAccess && !user.Premium;
+ }
+}
+
+
+
diff --git a/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs
new file mode 100644
index 000000000000..95c7019b361b
--- /dev/null
+++ b/src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs
@@ -0,0 +1,41 @@
+namespace Bit.Core.Billing.Premium.Queries;
+
+///
+/// Query for checking premium access status for users.
+/// This is the centralized location for determining if a user can access premium features
+/// (either through personal subscription or organization membership).
+///
+///
+/// Note: This is different from checking User.Premium, which only indicates
+/// personal subscription status. Use these methods to check actual premium feature access.
+///
+///
+public interface IHasPremiumAccessQuery
+{
+ ///
+ /// Checks if a user has access to premium features (personal subscription or organization).
+ /// This is the definitive way to check premium access for a single user.
+ ///
+ /// The user ID to check for premium access
+ /// True if user can access premium features; false otherwise
+ Task HasPremiumAccessAsync(Guid userId);
+
+ ///
+ /// Checks if a user has access to premium features through organization membership only.
+ /// This is useful for determining the source of premium access (personal vs organization).
+ ///
+ /// The user ID to check for organization premium access
+ /// True if user has premium access through any organization; false otherwise
+ Task HasPremiumFromOrganizationAsync(Guid userId);
+
+ ///
+ /// Checks if multiple users have access to premium features (optimized bulk operation).
+ /// Uses existing stored procedure that calculates premium from personal subscriptions and organizations.
+ ///
+ /// The user IDs to check for premium access
+ /// Dictionary mapping user IDs to their premium access status (personal or through organization)
+ Task> HasPremiumAccessAsync(IEnumerable userIds);
+}
+
+
+
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index af5b738cd0ab..c3f3cea76ae9 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -144,6 +144,7 @@ public static class FeatureFlagKeys
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
+ public const string PremiumAccessQuery = "pm-21411-premium-access-query";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs
index 1ca660677914..669e32bcbe8b 100644
--- a/src/Core/Entities/User.cs
+++ b/src/Core/Entities/User.cs
@@ -69,6 +69,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac
/// The security state is a signed object attesting to the version of the user's account.
///
public string? SecurityState { get; set; }
+ ///
+ /// Indicates whether the user has a personal premium subscription.
+ /// Does not include premium access from organizations -
+ /// do not use this to check whether the user can access premium features.
+ ///
public bool Premium { get; set; }
public DateTime? PremiumExpirationDate { get; set; }
public DateTime? RenewalReminderDate { get; set; }
diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs
index 22effb4329d5..db9151e0ddb1 100644
--- a/src/Core/Repositories/IUserRepository.cs
+++ b/src/Core/Repositories/IUserRepository.cs
@@ -23,6 +23,7 @@ public interface IUserRepository : IRepository
/// Retrieves the data for the requested user IDs and includes an additional property indicating
/// whether the user has premium access directly or through an organization.
///
+ [Obsolete("Use IUserService.CanAccessPremiumBulk instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")]
Task> GetManyWithCalculatedPremiumAsync(IEnumerable ids);
///
/// Retrieves the data for the requested user ID and includes additional property indicating
@@ -33,6 +34,7 @@ public interface IUserRepository : IRepository
///
/// The user ID to retrieve data for.
/// User data with calculated premium access; null if nothing is found
+ [Obsolete("Use IUserService.CanAccessPremium instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")]
Task GetCalculatedPremiumAsync(Guid userId);
///
/// Sets a new user key and updates all encrypted data.
diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs
index 70c62cb79ee5..c324a8b1d93f 100644
--- a/src/Core/Services/IUserService.cs
+++ b/src/Core/Services/IUserService.cs
@@ -64,6 +64,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI
///
/// user being acted on
/// true if they can access premium; false otherwise.
+ [Obsolete("Use IHasPremiumAccessQuery.HasPremiumAccessAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")]
Task CanAccessPremium(User user);
///
@@ -74,6 +75,7 @@ Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI
///
///
///
+ [Obsolete("Use IHasPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessQuery' is disabled.")]
Task HasPremiumFromOrganization(User user);
Task GenerateSignInTokenAsync(User user, string purpose);
diff --git a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs
index adeac45d062a..0856c3377fbc 100644
--- a/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs
+++ b/test/Core.Test/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQueryTests.cs
@@ -1,6 +1,7 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth;
+using Bit.Core.Billing.Premium.Queries;
using Bit.Core.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@@ -404,6 +405,407 @@ await sutProvider.GetDependency()
.GetCalculatedPremiumAsync(default);
}
+ [Theory]
+ [BitAutoData(TwoFactorProviderType.Authenticator)]
+ [BitAutoData(TwoFactorProviderType.Email)]
+ [BitAutoData(TwoFactorProviderType.Remember)]
+ [BitAutoData(TwoFactorProviderType.OrganizationDuo)]
+ [BitAutoData(TwoFactorProviderType.WebAuthn)]
+ public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeNotRequiringPremium_ReturnsAllTwoFactorEnabled(
+ TwoFactorProviderType freeProviderType,
+ SutProvider sutProvider,
+ List users)
+ {
+ // Arrange
+ var userIds = users.Select(u => u.Id).ToList();
+ var twoFactorProviders = new Dictionary
+ {
+ { freeProviderType, new TwoFactorProvider { Enabled = true } }
+ };
+
+ foreach (var user in users)
+ {
+ user.Premium = false;
+ user.SetTwoFactorProviders(twoFactorProviders);
+ }
+
+ var premiumStatus = users.ToDictionary(u => u.Id, u => false);
+
+ sutProvider.GetDependency()
+ .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains)))
+ .Returns(users);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(userIds)
+ .Returns(premiumStatus);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds);
+
+ // Assert
+ foreach (var user in users)
+ {
+ Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == true);
+ }
+ }
+
+ [Theory, BitAutoData]
+ public async Task TwoFactorIsEnabledVNextAsync_DatabaseReturnsEmpty_ResultEmpty(
+ SutProvider sutProvider,
+ List users)
+ {
+ // Arrange
+ var userIds = users.Select(u => u.Id).ToList();
+
+ sutProvider.GetDependency()
+ .GetManyAsync(Arg.Any>())
+ .Returns([]);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(Arg.Any>())
+ .Returns(new Dictionary());
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Theory]
+ [BitAutoData((IEnumerable)null)]
+ [BitAutoData([])]
+ public async Task TwoFactorIsEnabledVNextAsync_UserIdsNullorEmpty_ResultEmpty(
+ IEnumerable userIds,
+ SutProvider sutProvider)
+ {
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task TwoFactorIsEnabledVNextAsync_WithNoTwoFactorEnabled_ReturnsAllTwoFactorDisabled(
+ SutProvider sutProvider,
+ List users)
+ {
+ // Arrange
+ var userIds = users.Select(u => u.Id).ToList();
+ var twoFactorProviders = new Dictionary
+ {
+ { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } }
+ };
+
+ foreach (var user in users)
+ {
+ user.SetTwoFactorProviders(twoFactorProviders);
+ }
+
+ var premiumStatus = users.ToDictionary(u => u.Id, u => false);
+
+ sutProvider.GetDependency()
+ .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains)))
+ .Returns(users);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(userIds)
+ .Returns(premiumStatus);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds);
+
+ // Assert
+ foreach (var user in users)
+ {
+ Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == false);
+ }
+ }
+
+ [Theory]
+ [BitAutoData(TwoFactorProviderType.Duo)]
+ [BitAutoData(TwoFactorProviderType.YubiKey)]
+ public async Task TwoFactorIsEnabledVNextAsync_WithProviderTypeRequiringPremium_ReturnsMixedResults(
+ TwoFactorProviderType premiumProviderType,
+ SutProvider sutProvider,
+ List users)
+ {
+ // Arrange
+ var userIds = users.Select(u => u.Id).ToList();
+ var twoFactorProviders = new Dictionary
+ {
+ { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } },
+ { premiumProviderType, new TwoFactorProvider { Enabled = true } }
+ };
+
+ foreach (var user in users)
+ {
+ user.Premium = false;
+ user.SetTwoFactorProviders(twoFactorProviders);
+ }
+
+ // Only the first user has premium access
+ var premiumStatus = users.ToDictionary(
+ u => u.Id,
+ u => users.IndexOf(u) == 0);
+
+ sutProvider.GetDependency()
+ .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains)))
+ .Returns(users);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(userIds)
+ .Returns(premiumStatus);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds);
+
+ // Assert
+ foreach (var user in users)
+ {
+ var expectedEnabled = premiumStatus[user.Id];
+ Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == expectedEnabled);
+ }
+ }
+
+ [Theory]
+ [BitAutoData("")]
+ [BitAutoData("{}")]
+ [BitAutoData((string)null)]
+ public async Task TwoFactorIsEnabledVNextAsync_WithNullOrEmptyTwoFactorProviders_ReturnsAllTwoFactorDisabled(
+ string twoFactorProviders,
+ SutProvider sutProvider,
+ List users)
+ {
+ // Arrange
+ var userIds = users.Select(u => u.Id).ToList();
+
+ foreach (var user in users)
+ {
+ user.TwoFactorProviders = twoFactorProviders;
+ }
+
+ var premiumStatus = users.ToDictionary(u => u.Id, u => false);
+
+ sutProvider.GetDependency()
+ .GetManyAsync(Arg.Is>(i => i.All(userIds.Contains)))
+ .Returns(users);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(userIds)
+ .Returns(premiumStatus);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(userIds);
+
+ // Assert
+ foreach (var user in users)
+ {
+ Assert.Contains(result, res => res.userId == user.Id && res.twoFactorIsEnabled == false);
+ }
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task TwoFactorIsEnabledVNextAsync_Generic_WithNoUserIds_ReturnsAllTwoFactorDisabled(
+ SutProvider sutProvider,
+ List users)
+ {
+ // Arrange
+ foreach (var user in users)
+ {
+ user.UserId = null;
+ }
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(users);
+
+ // Assert
+ foreach (var user in users)
+ {
+ Assert.Contains(result, res => res.user.Equals(user) && res.twoFactorIsEnabled == false);
+ }
+
+ // No UserIds were supplied so no calls to the UserRepository should have been made
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .GetManyAsync(default);
+ }
+
+ [Theory]
+ [BitAutoData(TwoFactorProviderType.Authenticator)]
+ [BitAutoData(TwoFactorProviderType.Email)]
+ [BitAutoData(TwoFactorProviderType.Remember)]
+ [BitAutoData(TwoFactorProviderType.OrganizationDuo)]
+ [BitAutoData(TwoFactorProviderType.WebAuthn)]
+ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeNotRequiringPremium_ReturnsTrue(
+ TwoFactorProviderType freeProviderType,
+ SutProvider sutProvider,
+ User user)
+ {
+ // Arrange
+ var twoFactorProviders = new Dictionary
+ {
+ { freeProviderType, new TwoFactorProvider { Enabled = true } }
+ };
+
+ user.Premium = false;
+ user.SetTwoFactorProviders(twoFactorProviders);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user);
+
+ // Assert
+ Assert.True(result);
+
+ // Should not need to check premium access for free providers
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .HasPremiumAccessAsync(Arg.Any());
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNoTwoFactorEnabled_ReturnsFalse(
+ SutProvider sutProvider,
+ User user)
+ {
+ // Arrange
+ var twoFactorProviders = new Dictionary
+ {
+ { TwoFactorProviderType.Email, new TwoFactorProvider { Enabled = false } }
+ };
+
+ user.SetTwoFactorProviders(twoFactorProviders);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user);
+
+ // Assert
+ Assert.False(result);
+
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .HasPremiumAccessAsync(Arg.Any());
+ }
+
+ [Theory]
+ [BitAutoData(TwoFactorProviderType.Duo)]
+ [BitAutoData(TwoFactorProviderType.YubiKey)]
+ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequiringPremium_WithoutPremium_ReturnsFalse(
+ TwoFactorProviderType premiumProviderType,
+ SutProvider sutProvider,
+ User user)
+ {
+ // Arrange
+ var twoFactorProviders = new Dictionary
+ {
+ { premiumProviderType, new TwoFactorProvider { Enabled = true } }
+ };
+
+ user.Premium = false;
+ user.SetTwoFactorProviders(twoFactorProviders);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(user.Id)
+ .Returns(false);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user);
+
+ // Assert
+ Assert.False(result);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .HasPremiumAccessAsync(user.Id);
+ }
+
+ [Theory]
+ [BitAutoData(TwoFactorProviderType.Duo)]
+ [BitAutoData(TwoFactorProviderType.YubiKey)]
+ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequiringPremium_WithPersonalPremium_ReturnsTrue(
+ TwoFactorProviderType premiumProviderType,
+ SutProvider sutProvider,
+ User user)
+ {
+ // Arrange
+ var twoFactorProviders = new Dictionary
+ {
+ { premiumProviderType, new TwoFactorProvider { Enabled = true } }
+ };
+
+ user.Premium = true;
+ user.SetTwoFactorProviders(twoFactorProviders);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(user.Id)
+ .Returns(true);
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user);
+
+ // Assert
+ Assert.True(result);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .HasPremiumAccessAsync(user.Id);
+ }
+
+ [Theory]
+ [BitAutoData(TwoFactorProviderType.Duo)]
+ [BitAutoData(TwoFactorProviderType.YubiKey)]
+ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithProviderTypeRequiringPremium_WithOrgPremium_ReturnsTrue(
+ TwoFactorProviderType premiumProviderType,
+ SutProvider sutProvider,
+ User user)
+ {
+ // Arrange
+ var twoFactorProviders = new Dictionary
+ {
+ { premiumProviderType, new TwoFactorProvider { Enabled = true } }
+ };
+
+ user.Premium = false;
+ user.SetTwoFactorProviders(twoFactorProviders);
+
+ sutProvider.GetDependency()
+ .HasPremiumAccessAsync(user.Id)
+ .Returns(true); // Has premium from org
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user);
+
+ // Assert
+ Assert.True(result);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .HasPremiumAccessAsync(user.Id);
+ }
+
+ [Theory]
+ [BitAutoData]
+ public async Task TwoFactorIsEnabledVNextAsync_SingleUser_WithNullTwoFactorProviders_ReturnsFalse(
+ SutProvider sutProvider,
+ User user)
+ {
+ // Arrange
+ user.TwoFactorProviders = null;
+
+ // Act
+ var result = await sutProvider.Sut.TwoFactorIsEnabledVNextAsync(user);
+
+ // Assert
+ Assert.False(result);
+ await sutProvider.GetDependency()
+ .DidNotReceiveWithAnyArgs()
+ .HasPremiumAccessAsync(Arg.Any());
+ }
+
private class TestTwoFactorProviderUser : ITwoFactorProvidersUser
{
public Guid? Id { get; set; }
@@ -418,10 +820,5 @@ public Dictionary GetTwoFactorProvider
{
return Id;
}
-
- public bool GetPremium()
- {
- return Premium;
- }
}
}
diff --git a/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs
new file mode 100644
index 000000000000..c77a354589cc
--- /dev/null
+++ b/test/Core.Test/Billing/Premium/Queries/HasPremiumAccessQueryTests.cs
@@ -0,0 +1,213 @@
+using Bit.Core.Billing.Premium.Queries;
+using Bit.Core.Models.Data;
+using Bit.Core.Repositories;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Core.Test.Billing.Premium.Queries;
+
+[SutProviderCustomize]
+public class HasPremiumAccessQueryTests
+{
+ [Theory, BitAutoData]
+ public async Task HasPremiumAccessAsync_WhenUserHasPersonalPremium_ReturnsTrue(
+ UserWithCalculatedPremium user,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ user.Premium = true;
+ user.HasPremiumAccess = true;
+
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(user.Id)
+ .Returns(user);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumButHasOrgPremium_ReturnsTrue(
+ UserWithCalculatedPremium user,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ user.Premium = false;
+ user.HasPremiumAccess = true; // Has org premium
+
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(user.Id)
+ .Returns(user);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumAccessAsync_WhenUserHasNoPersonalPremiumAndNoOrgPremium_ReturnsFalse(
+ UserWithCalculatedPremium user,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ user.Premium = false;
+ user.HasPremiumAccess = false;
+
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(user.Id)
+ .Returns(user);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumAccessAsync(user.Id);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumAccessAsync_WhenUserNotFound_ReturnsFalse(
+ Guid userId,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(userId)
+ .Returns((UserWithCalculatedPremium)null);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumAccessAsync(userId);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumFromOrganizationAsync_WhenUserHasNoOrganizations_ReturnsFalse(
+ UserWithCalculatedPremium user,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ user.Premium = false;
+ user.HasPremiumAccess = false; // No premium from anywhere
+
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(user.Id)
+ .Returns(user);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumFromOrganizationAsync_WhenUserHasPremiumFromOrg_ReturnsTrue(
+ UserWithCalculatedPremium user,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ user.Premium = false; // No personal premium
+ user.HasPremiumAccess = true; // But has premium from org
+
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(user.Id)
+ .Returns(user);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumFromOrganizationAsync_WhenUserHasOnlyPersonalPremium_ReturnsFalse(
+ UserWithCalculatedPremium user,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ user.Premium = true; // Has personal premium
+ user.HasPremiumAccess = true;
+
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(user.Id)
+ .Returns(user);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(user.Id);
+
+ // Assert
+ Assert.False(result); // Should return false because premium is from personal, not org
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumFromOrganizationAsync_WhenUserNotFound_ReturnsFalse(
+ Guid userId,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ sutProvider.GetDependency()
+ .GetCalculatedPremiumAsync(userId)
+ .Returns((UserWithCalculatedPremium)null);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumFromOrganizationAsync(userId);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumAccessAsync_Bulk_WhenEmptyList_ReturnsEmptyDictionary(
+ SutProvider sutProvider)
+ {
+ // Arrange
+ var userIds = new List();
+
+ sutProvider.GetDependency()
+ .GetManyWithCalculatedPremiumAsync(userIds)
+ .Returns(new List());
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task HasPremiumAccessAsync_Bulk_ReturnsCorrectStatus(
+ List users,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ users[0].HasPremiumAccess = true;
+ users[1].HasPremiumAccess = false;
+ users[2].HasPremiumAccess = true;
+
+ var userIds = users.Select(u => u.Id).ToList();
+
+ sutProvider.GetDependency()
+ .GetManyWithCalculatedPremiumAsync(userIds)
+ .Returns(users);
+
+ // Act
+ var result = await sutProvider.Sut.HasPremiumAccessAsync(userIds);
+
+ // Assert
+ Assert.Equal(users.Count, result.Count);
+ Assert.True(result[users[0].Id]);
+ Assert.False(result[users[1].Id]);
+ Assert.True(result[users[2].Id]);
+ }
+}
+
+