Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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,10 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I
public string Email { get; set; }
public string AvatarColor { get; set; }
public string TwoFactorProviders { get; set; }
/// <summary>
/// User's personal premium subscription status. Does not reflect organization premium access.
/// 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 Expand Up @@ -63,11 +67,6 @@ public Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProvider
return UserId;
}

public bool GetPremium()
{
return Premium.GetValueOrDefault(false);
}

public Permissions GetPermissions()
{
return string.IsNullOrWhiteSpace(Permissions) ? null
Expand Down
17 changes: 10 additions & 7 deletions src/Core/Auth/Models/ITwoFactorProvidersUser.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
๏ปฟ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using Bit.Core.Auth.Enums;
๏ปฟusing Bit.Core.Auth.Enums;
using Bit.Core.Services;

namespace Bit.Core.Auth.Models;

/// <summary>
/// An interface representing a user entity that supports two-factor providers
/// </summary>
public interface ITwoFactorProvidersUser
{
string TwoFactorProviders { get; }
string? TwoFactorProviders { get; }
/// <summary>
/// Get the two factor providers for the user. Currently it can be assumed providers are enabled
/// if they exists in the dictionary. When two factor providers are disabled they are removed
/// from the dictionary. <see cref="IUserService.DisableTwoFactorProviderAsync"/>
/// <see cref="IOrganizationService.DisableTwoFactorProviderAsync"/>
/// </summary>
/// <returns>Dictionary of providers with the type enum as the key</returns>
Dictionary<TwoFactorProviderType, TwoFactorProvider> GetTwoFactorProviders();
Dictionary<TwoFactorProviderType, TwoFactorProvider>? GetTwoFactorProviders();
/// <summary>
/// The unique `UserId` of the user entity for which there are two-factor providers configured.
/// </summary>
/// <returns>The unique identifier for the user</returns>
Guid? GetUserId();
bool GetPremium();
}
50 changes: 50 additions & 0 deletions src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
๏ปฟusing Bit.Core.Entities;

namespace Bit.Core.Auth.UserFeatures.PremiumAccess;

/// <summary>
/// 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).
///
/// <para>
/// <strong>Note:</strong> This is different from checking User.Premium, which only indicates
/// personal subscription status. Use these methods to check actual premium feature access.
/// </para>
/// </summary>
public interface IPremiumAccessQuery
{
/// <summary>
/// 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.
/// </summary>
/// <param name="user">The user to check for premium access</param>
/// <returns>True if user can access premium features; false otherwise</returns>
Task<bool> CanAccessPremiumAsync(User user);

/// <summary>
/// Checks if a user has access to premium features (personal subscription or organization).
/// Use this overload when you already know the personal premium status and only need to check organization premium.
/// </summary>
/// <param name="userId">The user ID to check for premium access</param>
/// <param name="hasPersonalPremium">Whether the user has a personal premium subscription</param>
/// <returns>True if user can access premium features; false otherwise</returns>
Task<bool> CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium);

/// <summary>
/// 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).
/// </summary>
/// <param name="userId">The user ID to check for organization premium access</param>
/// <returns>True if user has premium access through any organization; false otherwise</returns>
Task<bool> HasPremiumFromOrganizationAsync(Guid userId);

/// <summary>
/// Checks if multiple users have access to premium features (optimized bulk operation).
/// Uses cached organization abilities and minimizes database queries.
/// </summary>
/// <param name="users">The users to check for premium access</param>
/// <returns>Dictionary mapping user IDs to their premium access status (personal or through organization)</returns>
Task<Dictionary<Guid, bool>> CanAccessPremiumBulkAsync(IEnumerable<User> users);
}

97 changes: 97 additions & 0 deletions src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;

namespace Bit.Core.Auth.UserFeatures.PremiumAccess;

/// <summary>
/// Query for checking premium access status for users using cached organization abilities.
/// </summary>
public class PremiumAccessQuery : IPremiumAccessQuery
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IApplicationCacheService _applicationCacheService;

public PremiumAccessQuery(
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService)
{
_organizationUserRepository = organizationUserRepository;
_applicationCacheService = applicationCacheService;
}

public async Task<bool> CanAccessPremiumAsync(User user)
{
return await CanAccessPremiumAsync(user.Id, user.Premium);
}

public async Task<bool> CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium)
{
if (hasPersonalPremium)
{
return true;
}

return await HasPremiumFromOrganizationAsync(userId);
}

public async Task<bool> HasPremiumFromOrganizationAsync(Guid userId)
{
// Note: GetManyByUserAsync only returns Accepted and Confirmed status org users
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(userId);
if (!orgUsers.Any())
{
return false;
}

var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
return orgUsers.Any(ou =>
orgAbilities.TryGetValue(ou.OrganizationId, out var orgAbility) &&
orgAbility.UsersGetPremium &&
orgAbility.Enabled);
}

public async Task<Dictionary<Guid, bool>> CanAccessPremiumBulkAsync(IEnumerable<User> users)
{
var result = new Dictionary<Guid, bool>();
var usersList = users.ToList();

if (!usersList.Any())
{
return result;
}

var userIds = usersList.Select(u => u.Id).ToList();

// Get all org memberships for these users in one query
// Note: GetManyByManyUsersAsync only returns Accepted and Confirmed status org users
var allOrgUsers = await _organizationUserRepository.GetManyByManyUsersAsync(userIds);
var orgUsersGrouped = allOrgUsers
.Where(ou => ou.UserId.HasValue)
.GroupBy(ou => ou.UserId!.Value)
.ToDictionary(g => g.Key, g => g.ToList());

var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();

foreach (var user in usersList)
{
var hasPersonalPremium = user.Premium;
if (hasPersonalPremium)
{
result[user.Id] = true;
continue;
}

var hasPremiumFromOrg = orgUsersGrouped.TryGetValue(user.Id, out var userOrgs) &&
userOrgs.Any(ou =>
orgAbilities.TryGetValue(ou.OrganizationId, out var orgAbility) &&
orgAbility.UsersGetPremium &&
orgAbility.Enabled);

result[user.Id] = hasPremiumFromOrg;
}

return result;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,30 @@

using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.PremiumAccess;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
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 IPremiumAccessQuery _premiumAccessQuery;
private readonly IFeatureService _featureService;

public TwoFactorIsEnabledQuery(
IUserRepository userRepository,
IPremiumAccessQuery premiumAccessQuery,
IFeatureService featureService)
{
_userRepository = userRepository;
_premiumAccessQuery = premiumAccessQuery;
_featureService = featureService;
}

public async Task<IEnumerable<(Guid userId, bool twoFactorIsEnabled)>> TwoFactorIsEnabledAsync(IEnumerable<Guid> userIds)
{
Expand All @@ -20,15 +36,34 @@ public class TwoFactorIsEnabledQuery(IUserRepository userRepository) : ITwoFacto
return result;
}

var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]);
foreach (var userDetail in userDetails)
if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck))
{
result.Add(
(userDetail.Id,
await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(),
() => Task.FromResult(userDetail.HasPremiumAccess))
)
);
var users = await _userRepository.GetManyAsync([.. userIds]);
var premiumStatus = await _premiumAccessQuery.CanAccessPremiumBulkAsync(users);

foreach (var user in users)
{
result.Add(
(user.Id,
await TwoFactorEnabledAsync(
user.GetTwoFactorProviders(),
() => Task.FromResult(premiumStatus.GetValueOrDefault(user.Id, false))
))
);
}
}
else
{
var userDetails = await _userRepository.GetManyWithCalculatedPremiumAsync([.. userIds]);
foreach (var userDetail in userDetails)
{
result.Add(
(userDetail.Id,
await TwoFactorEnabledAsync(userDetail.GetTwoFactorProviders(),
() => Task.FromResult(userDetail.HasPremiumAccess))
)
);
}
}

return result;
Expand Down Expand Up @@ -71,13 +106,43 @@ public async Task<bool> TwoFactorIsEnabledAsync(ITwoFactorProvidersUser user)
return false;
}

return await TwoFactorEnabledAsync(
user.GetTwoFactorProviders(),
async () =>
{
var calcUser = await _userRepository.GetCalculatedPremiumAsync(userId.Value);
return calcUser?.HasPremiumAccess ?? false;
});
if (_featureService.IsEnabled(FeatureFlagKeys.PremiumAccessCacheCheck))
{
// Try to get premium status without fetching User entity if possible
bool hasPersonalPremium;
if (user is User userEntity)
{
hasPersonalPremium = userEntity.Premium;
}
else if (user is OrganizationUserUserDetails orgUserDetails)
{
hasPersonalPremium = orgUserDetails.Premium.GetValueOrDefault(false);
}
else
{
// Fallback: fetch the User entity
var fetchedUser = await _userRepository.GetByIdAsync(userId.Value);
if (fetchedUser == null)
{
return false;
}
hasPersonalPremium = fetchedUser.Premium;
}

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

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
๏ปฟusing Bit.Core.Auth.Sso;
using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Auth.UserFeatures.PremiumAccess;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
Expand Down Expand Up @@ -27,6 +28,7 @@ public static void AddUserServices(this IServiceCollection services, IGlobalSett
services.AddUserRegistrationCommands();
services.AddWebAuthnLoginCommands();
services.AddTdeOffboardingPasswordCommands();
services.AddPremiumAccessQueries();
services.AddTwoFactorQueries();
services.AddSsoQueries();
}
Expand Down Expand Up @@ -65,6 +67,11 @@ private static void AddWebAuthnLoginCommands(this IServiceCollection services)
services.AddScoped<IAssertWebAuthnLoginCredentialCommand, AssertWebAuthnLoginCredentialCommand>();
}

private static void AddPremiumAccessQueries(this IServiceCollection services)
{
services.AddScoped<IPremiumAccessQuery, PremiumAccessQuery>();
}

private static void AddTwoFactorQueries(this IServiceCollection services)
{
services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>();
Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 PremiumAccessCacheCheck = "pm-21411-premium-access-cache-check";

/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
Expand Down
9 changes: 4 additions & 5 deletions src/Core/Entities/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ 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.
/// </summary>
public bool Premium { get; set; }
public DateTime? PremiumExpirationDate { get; set; }
public DateTime? RenewalReminderDate { get; set; }
Expand Down Expand Up @@ -200,11 +204,6 @@ given user does or doesn't have this enabled. It is a non-zero chance.
return Id;
}

public bool GetPremium()
{
return Premium;
}

public int GetSecurityVersion()
{
// If no security version is set, it is version 1. The minimum initialized version is 2.
Expand Down
Loading
Loading