Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
5629d6f
Removed 2FA user interface from premium method signatures
trmartin4 Dec 4, 2025
c09a973
Added some more comments for clarity and small touchups.
Patrick-Pimentel-Bitwarden Dec 4, 2025
f83a790
Add PremiumAccessCacheCheck feature flag to Constants.cs
r-tome Dec 4, 2025
d30f3bd
Add IPremiumAccessQuery interface and PremiumAccessQuery implementatiโ€ฆ
r-tome Dec 4, 2025
377ae31
Add unit tests for PremiumAccessQuery to validate user premium accessโ€ฆ
r-tome Dec 4, 2025
683a6cb
Add XML documentation to Premium in OrganizationUserUserDetails and Uโ€ฆ
r-tome Dec 4, 2025
08e1413
Add PremiumAccessQueries to UserServiceCollectionExtensions
r-tome Dec 4, 2025
e494a10
Refactor TwoFactorIsEnabledQuery to incorporate PremiumAccessQuery anโ€ฆ
r-tome Dec 4, 2025
0cdadd1
Mark methods in IUserRepository and IUserService as obsolete, directiโ€ฆ
r-tome Dec 4, 2025
192052b
Rename CanAccessPremiumBulkAsync to CanAccessPremiumAsync in IPremiumโ€ฆ
r-tome Dec 5, 2025
2d31237
Update TwoFactorIsEnabledQuery to use CanAccessPremiumAsync for premiโ€ฆ
r-tome Dec 5, 2025
123f579
Refactor TwoFactorIsEnabledQuery to introduce VNextAsync methods for โ€ฆ
r-tome Dec 5, 2025
57252a7
Refactor IPremiumAccessQuery and PremiumAccessQuery to remove the oveโ€ฆ
r-tome Dec 5, 2025
bf0cc7a
Add new sync static method to determine if TwoFactor is enabled
r-tome Dec 5, 2025
65941d4
Enhance XML documentation for Premium property in OrganizationUserUseโ€ฆ
r-tome Dec 5, 2025
2a966e3
Refactor IPremiumAccessQuery and PremiumAccessQuery to replace User pโ€ฆ
r-tome Dec 5, 2025
96a3615
Update feature flag references in IUserRepository and IUserService toโ€ฆ
r-tome Dec 5, 2025
516824a
Rename IPremiumAccessQuery to IHasPremiumAccessQuery and move to Billโ€ฆ
r-tome Dec 5, 2025
b8ff0ea
Remove unnecessary whitespace from IHasPremiumAccessQuery interface.
r-tome Dec 8, 2025
415a0b9
Refactor HasPremiumAccessQuery to throw NotFoundException for null users
r-tome Dec 8, 2025
303b81c
Add NotFoundException handling in HasPremiumAccessQuery for mismatcheโ€ฆ
r-tome Dec 8, 2025
30ff3da
Refactor TwoFactorIsEnabledQuery to optimize premium access checks anโ€ฆ
r-tome Dec 8, 2025
02c2aa8
Refactor TwoFactorIsEnabledQueryTests to enhance clarity and optimizeโ€ฆ
r-tome Dec 8, 2025
2184296
Add UserPremiumAccess model to represent user premium access status fโ€ฆ
r-tome Dec 8, 2025
5257dc5
Add User_ReadPremiumAccessByIds stored procedure and UserPremiumAccesโ€ฆ
r-tome Dec 8, 2025
97bcf41
Add SQL migration script
r-tome Dec 8, 2025
0a42c9e
Add premium access retrieval methods to IUserRepository and implementโ€ฆ
r-tome Dec 8, 2025
651d5e3
Refactor HasPremiumAccessQuery and IHasPremiumAccessQuery to streamliโ€ฆ
r-tome Dec 8, 2025
353a07a
Update IUserRepository to reflect new method names for premium accessโ€ฆ
r-tome Dec 8, 2025
da9f0a1
Refactor TwoFactorIsEnabledQuery to utilize IFeatureService for premiโ€ฆ
r-tome Dec 8, 2025
df7bd08
Enhance EF UserRepository to improve premium access retrieval by inclโ€ฆ
r-tome Dec 8, 2025
cd0b3da
Add unit tests for premium access retrieval in UserRepositoryTests.
r-tome Dec 8, 2025
245ce71
Optimize HasPremiumAccessQuery to eliminate duplicate user IDs beforeโ€ฆ
r-tome Dec 8, 2025
0279cb5
Refactor TwoFactorIsEnabledQuery to improve handling of users withoutโ€ฆ
r-tome Dec 8, 2025
1415517
Merge branch 'main' into ac/pm-21411/refactor-interface-for-premium-sโ€ฆ
r-tome Dec 9, 2025
2bd00c2
Update HasPremiumAccessQueryTests to use simplified exception handlinโ€ฆ
r-tome Dec 9, 2025
dbb8619
Enhance TwoFactorIsEnabledQuery to throw NotFoundException for non-exโ€ฆ
r-tome Dec 9, 2025
2f11c13
Move premium access query to Billing owned ServiceCollectionExtensions
r-tome Dec 9, 2025
9a68a97
Refactor IUserService to enhance premium access checks
r-tome Dec 9, 2025
1cd5749
Update IUserRepository to clarify usage of premium access methods
r-tome Dec 9, 2025
61775fb
Update IUserRepository and IUserService to clarify deprecation of preโ€ฆ
r-tome Dec 9, 2025
19627f4
Refactor TwoFactorIsEnabledQuery to streamline user ID retrieval
r-tome Dec 9, 2025
6a783ff
Rename migration script to fix the date
r-tome Dec 9, 2025
85e0e1a
Merge branch 'main' into ac/pm-21411/refactor-interface-for-premium-sโ€ฆ
r-tome Dec 11, 2025
f833040
Merge branch 'main' into ac/pm-21411/refactor-interface-for-premium-sโ€ฆ
r-tome Dec 12, 2025
de13a7c
Update migration script to create the index with DROP_EXISTING = ON
r-tome Dec 12, 2025
03b0431
Refactor UserPremiumAccessView to use LEFT JOINs and GROUP BY for impโ€ฆ
r-tome Dec 12, 2025
125b4de
Update HasPremiumAccessQueryTests to return null for GetPremiumAccessโ€ฆ
r-tome Dec 12, 2025
2081dd4
Add unit tests for premium access scenarios in UserRepositoryTests
r-tome Dec 12, 2025
de42f20
Bump date on migration script
r-tome Dec 12, 2025
33a464a
Update OrganizationEntityTypeConfiguration to include UsersGetPremiumโ€ฆ
r-tome Dec 12, 2025
97bf24e
Add migration scripts for OrganizationUsersGetPremiumIndex across MySโ€ฆ
r-tome Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
/// <summary>
/// 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).
/// </summary>
public bool? Premium { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
Expand Down
163 changes: 155 additions & 8 deletions src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,37 @@
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Premium.Queries;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;

namespace Bit.Core.Auth.UserFeatures.TwoFactorAuth;

public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFactorIsEnabledQuery
public class TwoFactorIsEnabledQuery : ITwoFactorIsEnabledQuery
{
private readonly IUserRepository _userRepository = userRepository;
private readonly IUserRepository _userRepository;
private readonly IHasPremiumAccessQuery _hasPremiumAccessQuery;
private readonly IFeatureService _featureService;

public TwoFactorIsEnabledQuery(
IUserRepository userRepository,
IHasPremiumAccessQuery hasPremiumAccessQuery,
IFeatureService featureService)
{
_userRepository = userRepository;
_hasPremiumAccessQuery = hasPremiumAccessQuery;
_featureService = featureService;
}

public async Task<IEnumerable<(Guid userId, bool twoFactorIsEnabled)>> TwoFactorIsEnabledAsync(IEnumerable<Guid> userIds)
{
if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery))
{
return await TwoFactorIsEnabledVNextAsync(userIds);
}

var result = new List<(Guid userId, bool hasTwoFactor)>();
if (userIds == null || !userIds.Any())
{
Expand All @@ -36,6 +57,11 @@ await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(),

public async Task<IEnumerable<(T user, bool twoFactorIsEnabled)>> TwoFactorIsEnabledAsync<T>(IEnumerable<T> users) where T : ITwoFactorProvidersUser
{
if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery))
{
return await TwoFactorIsEnabledVNextAsync(users);
}

var userIds = users
.Select(u => u.GetUserId())
.Where(u => u.HasValue)
Expand Down Expand Up @@ -71,13 +97,134 @@ public async Task<bool> TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user)
return false;
}

if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessQuery))
{
var userEntity = user as User ?? await _userRepository.GetByIdAsync(userId.Value);
if (userEntity == null)
{
throw new NotFoundException();
}

return await TwoFactorIsEnabledVNextAsync(userEntity);
}

return await TwoFactorEnabledAsync(
user.GetTwoFactorProviders(),
async () =>
{
var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value);
return calcUser?.HasPremiumAccess ?? false;
});
user.GetTwoFactorProviders(),
async () =>
{
var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value);
return calcUser?.HasPremiumAccess ?? false;
});
}

private async Task<IEnumerable<(Guid userId, bool twoFactorIsEnabled)>> TwoFactorIsEnabledVNextAsync(IEnumerable<Guid> userIds)
{
var result = new List<(Guid userId, bool hasTwoFactor)>();
if (userIds == null || !userIds.Any())
{
return result;
}

var users = await _userRepository.GetManyAsync([.. userIds]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Missing validation: users count != userIds count

If some user IDs don't exist in the database, GetManyAsync will return fewer users than requested. This could lead to silent failures where non-existent users are treated as having 2FA disabled.

Suggested fix:

var users = await _userRepository.GetManyAsync([.. userIds]);

if (users.Count() != userIds.Distinct().Count())
{
    var foundUserIds = users.Select(u => u.Id).ToHashSet();
    var missingIds = userIds.Where(id => !foundUserIds.Contains(id));
    throw new NotFoundException($"Users not found: {string.Join(", ", missingIds)}");
}

This is especially important since the single-user overload throws NotFoundException when a user isn't found (line 105).


// Get enabled providers for each user
var usersTwoFactorProvidersMap = users.ToDictionary(u => u.Id, GetEnabledTwoFactorProviders);

// Bulk fetch premium status only for users who need it (those with only premium providers)
var userIdsNeedingPremium = usersTwoFactorProvidersMap
.Where(kvp => kvp.Value.Any() && kvp.Value.All(TwoFactorProvider.RequiresPremium))
.Select(kvp => kvp.Key)
.ToList();

var premiumStatusMap = userIdsNeedingPremium.Count > 0
? await _hasPremiumAccessQuery.HasPremiumAccessAsync(userIdsNeedingPremium)
: new Dictionary<Guid, bool>();

foreach (var user in users)
{
var userTwoFactorProviders = usersTwoFactorProvidersMap[user.Id];

if (!userTwoFactorProviders.Any())
{
result.Add((user.Id, false));
continue;
}

// User has providers. If they're in the premium check map, verify premium status
var twoFactorIsEnabled = !premiumStatusMap.TryGetValue(user.Id, out var hasPremium) || hasPremium;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿค” Logic clarification needed

This line is subtle and could be clearer:

var twoFactorIsEnabled = !premiumStatusMap.TryGetValue(user.Id, out var hasPremium) || hasPremium;

This means:

  • If user is NOT in premiumStatusMap (has non-premium providers) โ†’ true
  • If user IS in premiumStatusMap โ†’ return their premium status

Consider adding a comment or refactoring for clarity:

// If user wasn't checked for premium (has free providers), they're enabled
// Otherwise, they're only enabled if they have premium
var twoFactorIsEnabled = !premiumStatusMap.TryGetValue(user.Id, out var hasPremium) || hasPremium;

result.Add((user.Id, twoFactorIsEnabled));
}

return result;
}

private async Task<IEnumerable<(T user, bool twoFactorIsEnabled)>> TwoFactorIsEnabledVNextAsync<T>(IEnumerable<T> 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue: FirstOrDefault returns default tuple, not null

When userId is found but twoFactorResults doesn't contain it, FirstOrDefault will return (Guid.Empty, false) instead of indicating a missing user. This could mask bugs where users are expected but not found.

Consider:

var twoFactorResult = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value);
if (twoFactorResult == default)
{
    throw new NotFoundException($"Two-factor status not found for user {userId.Value}");
}
result.Add((user, twoFactorResult.twoFactorIsEnabled));

This would make failures more explicit and easier to debug.

result.Add((user, hasTwoFactor));
}
else
{
result.Add((user, false));
}
}

return result;
}

private async Task<bool> TwoFactorIsEnabledVNextAsync(User user)
{
var enabledProviders = GetEnabledTwoFactorProviders(user);

if (!enabledProviders.Any())
{
return false;
}

// If all providers require premium, check if user has premium access
if (enabledProviders.All(TwoFactorProvider.RequiresPremium))
{
return await _hasPremiumAccessQuery.HasPremiumAccessAsync(user.Id);
}

// User has at least one non-premium provider
return true;
}

/// <summary>
/// Gets all enabled two-factor provider types for a user.
/// </summary>
/// <param name="user">user with two factor providers</param>
/// <returns>list of enabled provider types</returns>
private static IList<TwoFactorProviderType> GetEnabledTwoFactorProviders(User user)
{
var providers = user.GetTwoFactorProviders();

if (providers == null || providers.Count == 0)
{
return Array.Empty<TwoFactorProviderType>();
}

// TODO: PM-21210: In practice we don't save disabled providers to the database, worth looking into.
return (from provider in providers
where provider.Value?.Enabled ?? false
select provider.Key).ToList();
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Payment;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Premium.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
Expand All @@ -31,6 +32,7 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddPaymentOperations();
services.AddOrganizationLicenseCommandsQueries();
services.AddPremiumCommands();
services.AddPremiumQueries();
services.AddTransient<IGetOrganizationMetadataQuery, GetOrganizationMetadataQuery>();
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
Expand All @@ -50,4 +52,9 @@ private static void AddPremiumCommands(this IServiceCollection services)
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
}

private static void AddPremiumQueries(this IServiceCollection services)
{
services.AddScoped<IHasPremiumAccessQuery, HasPremiumAccessQuery>();
}
}
29 changes: 29 additions & 0 deletions src/Core/Billing/Premium/Models/UserPremiumAccess.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
๏ปฟnamespace Bit.Core.Billing.Premium.Models;

/// <summary>
/// Represents user premium access status from personal subscriptions and organization memberships.
/// </summary>
public class UserPremiumAccess
{
/// <summary>
/// The unique identifier for the user.
/// </summary>
public Guid Id { get; set; }

/// <summary>
/// Indicates whether the user has a personal premium subscription.
/// This does NOT include premium access from organizations.
/// </summary>
public bool PersonalPremium { get; set; }

/// <summary>
/// Indicates whether the user has premium access through any organization membership.
/// This is true if the user is a member of at least one enabled organization that grants premium access to users.
/// </summary>
public bool OrganizationPremium { get; set; }

/// <summary>
/// Indicates whether the user has premium access from any source (personal subscription or organization).
/// </summary>
public bool HasPremiumAccess => PersonalPremium || OrganizationPremium;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ‘ Nice: Computed property is clear and correct

The HasPremiumAccess computed property correctly implements the OR logic for premium access. This is clean and self-documenting.

}
49 changes: 49 additions & 0 deletions src/Core/Billing/Premium/Queries/HasPremiumAccessQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
๏ปฟusing Bit.Core.Exceptions;
using Bit.Core.Repositories;

namespace Bit.Core.Billing.Premium.Queries;

public class HasPremiumAccessQuery : IHasPremiumAccessQuery
{
private readonly IUserRepository _userRepository;

public HasPremiumAccessQuery(IUserRepository userRepository)
{
_userRepository = userRepository;
}

public async Task<bool> HasPremiumAccessAsync(Guid userId)
{
var user = await _userRepository.GetPremiumAccessAsync(userId);
if (user == null)
{
throw new NotFoundException();
}

return user.HasPremiumAccess;
}

public async Task<Dictionary<Guid, bool>> HasPremiumAccessAsync(IEnumerable<Guid> userIds)
{
var distinctUserIds = userIds.Distinct().ToList();
var usersWithPremium = await _userRepository.GetPremiumAccessByIdsAsync(distinctUserIds);

if (usersWithPremium.Count() != distinctUserIds.Count)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐ŸŽจ Minor: Use LINQ Count() for better clarity

Consider using .Count instead of .Count() for better performance with collections, or simplify the comparison:

if (usersWithPremium.Count != distinctUserIds.Count)

This avoids potential enumeration overhead if usersWithPremium is a lazy enumerable.

{
throw new NotFoundException();
}

return usersWithPremium.ToDictionary(u => u.Id, u => u.HasPremiumAccess);
}

public async Task<bool> HasPremiumFromOrganizationAsync(Guid userId)
{
var user = await _userRepository.GetPremiumAccessAsync(userId);
if (user == null)
{
throw new NotFoundException();
}

return user.OrganizationPremium;
}
}
30 changes: 30 additions & 0 deletions src/Core/Billing/Premium/Queries/IHasPremiumAccessQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
๏ปฟnamespace Bit.Core.Billing.Premium.Queries;

/// <summary>
/// Centralized query for checking if users have premium access through personal subscriptions or organizations.
/// Note: Different from User.Premium which only checks personal subscriptions.
/// </summary>
public interface IHasPremiumAccessQuery
{
/// <summary>
/// Checks if a user has premium access (personal or organization).
/// </summary>
/// <param name="userId">The user ID to check</param>
/// <returns>True if user can access premium features</returns>
Task<bool> HasPremiumAccessAsync(Guid userId);

/// <summary>
/// Checks premium access for multiple users.
/// </summary>
/// <param name="userIds">The user IDs to check</param>
/// <returns>Dictionary mapping user IDs to their premium access status</returns>
Task<Dictionary<Guid, bool>> HasPremiumAccessAsync(IEnumerable<Guid> userIds);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ“ Documentation improvement: Specify exception behavior

The interface documentation should specify what happens when users aren't found. Currently the implementation throws NotFoundException, but this isn't documented.

/// <summary>
/// Checks premium access for multiple users.
/// </summary>
/// <param name="userIds">The user IDs to check</param>
/// <returns>Dictionary mapping user IDs to their premium access status</returns>
/// <exception cref="NotFoundException">Thrown when any of the requested user IDs are not found</exception>
Task<Dictionary<Guid, bool>> HasPremiumAccessAsync(IEnumerable<Guid> userIds);


/// <summary>
/// Checks if a user belongs to any organization that grants premium (enabled org with UsersGetPremium).
/// Returns true regardless of personal subscription. Useful for UI decisions like showing subscription options.
/// </summary>
/// <param name="userId">The user ID to check</param>
/// <returns>True if user is in any organization that grants premium</returns>
Task<bool> HasPremiumFromOrganizationAsync(Guid userId);
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ public static class FeatureFlagKeys
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
public const string PremiumAccessQuery = "pm-21411-premium-access-query";

/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
Expand Down
5 changes: 5 additions & 0 deletions src/Core/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
/// The security state is a signed object attesting to the version of the user's account.
/// </summary>
public string? SecurityState { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool Premium { get; set; }
public DateTime? PremiumExpirationDate { get; set; }
public DateTime? RenewalReminderDate { get; set; }
Expand Down
Loading
Loading