Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
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
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);
Copy link
Member

Choose a reason for hiding this comment

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

๐ŸŽจ I think this is a good use case for an overload rather than adding Bulk to the name.

}

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;
}
Copy link
Member

@eliykat eliykat Dec 5, 2025

Choose a reason for hiding this comment

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

This has a database call + cache + a nested loop to get information that ultimately comes from the database anyway.

I suggest a simpler implementation would be a new sproc for this purpose (untested):

    SELECT
        U.[Id],
        MAX(U.[Premium]) PersonalPremium,
        MAX(O.[UsersGetPremium]) OrganizationPremium
    FROM
        [dbo].[UserView] U
        JOIN [dbo].[OrganizationUserView] OU ON U.Id = OU.UserId
        JOIN [dbo].[Organization] O ON OU.OrganizationId = O.Id
    WHERE
        U.[Id] IN (SELECT [Id] FROM @Ids)
    GROUP BY
        U.[Id]

And then this code could just return PersonalPremium || OrganizationPremium for each user.

I think this is better than the current sproc because it's not returning this weird UserWithCalculatedPremium object, it's very clear what its purpose is, and it's only used by this query.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm using the existing UserWithCalculatedPremium for now. I'll refactor that later here on in a separate branch

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're totally right, btw. I think initially I didn't retrieve the many users and just used the cache but for some reason this evolved and I kept that logic.

}

Copy link
Member

Choose a reason for hiding this comment

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

I think you still need to wire up the new methods here. (preferably just have the old methods call them and return the 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))
)
);
}
Copy link
Member

Choose a reason for hiding this comment

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

We shouldn't call an async method in a loop, even though it's a kind of fake async (using Task.FromResult), it's too liable to be misused. This is where I think writing a fresh implementation will help.

}

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;
}
Copy link
Member

Choose a reason for hiding this comment

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

This is trying to do the job of your new query, which is meant to be the only implementation of this logic. Some options:

  • maybe Auth were wrong to remove the GetPremium method from ITwoFactorProvidersUser? It was unused, but it seems like you have a use for it here. Although if you go this way, I would do it like this:
public interface IPremiumUser
{
  public bool HasPremium { get; set; } // move this to its own interface
}

public interface ITwoFactorProvidersUser : IPremiumUser // this inherits it
{
  // snip
}

public interface IPremiumAccessQuery
{
      Task<bool> CanAccessPremiumAsync(IPremiumUser user); // accepts the interface instead of any particular object

  // snip
}

// and now in this method, you can just pass the argument along...
_premiumAccessQuery.CanAccessPremiumAsync(user);

Other options:

  • require the caller to pass in a User to begin with
  • just always fetch the User, don't worry about it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I went with the simplest option of just having the callers pass in User objects. I tried to go with the IPremiumUser solution but it was more complex than it needed to because we also needed the Id for the bulk method


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
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
4 changes: 4 additions & 0 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
2 changes: 2 additions & 0 deletions src/Core/Repositories/IUserRepository.cs
Copy link
Member

@eliykat eliykat Dec 6, 2025

Choose a reason for hiding this comment

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

These methods are no longer obsolete, because your new query is using it. Although you may still want to mention that this is for internal use only, and devs should use the query instead.

All of that said - I think this sproc could be simplified per my last review, and now is a good time to do it. (i.e. by making a new sproc used by the new code, then deleting this when you clean up your feature flag). I'll leave it up to you though. It could also be a separate ticket.

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public interface IUserRepository : IRepository<User, Guid>
/// 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.
/// </summary>
[Obsolete("Use IUserService.CanAccessPremiumBulk instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")]
Task<IEnumerable<UserWithCalculatedPremium>> GetManyWithCalculatedPremiumAsync(IEnumerable<Guid> ids);
/// <summary>
/// Retrieves the data for the requested user ID and includes additional property indicating
Expand All @@ -33,6 +34,7 @@ public interface IUserRepository : IRepository<User, Guid>
/// </summary>
/// <param name="userId">The user ID to retrieve data for.</param>
/// <returns>User data with calculated premium access; null if nothing is found</returns>
[Obsolete("Use IUserService.CanAccessPremium instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")]
Task<UserWithCalculatedPremium?> GetCalculatedPremiumAsync(Guid userId);
/// <summary>
/// Sets a new user key and updates all encrypted data.
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Task<UserLicense> GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI
/// </summary>
/// <param name="user">user being acted on</param>
/// <returns>true if they can access premium; false otherwise.</returns>
[Obsolete("Use IPremiumAccessQuery.CanAccessPremiumAsync instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")]
Task<bool> CanAccessPremium(User user);

/// <summary>
Expand All @@ -74,6 +75,7 @@ Task<UserLicense> GenerateLicenseAsync(User user, SubscriptionInfo subscriptionI
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
[Obsolete("Use IPremiumAccessQuery.HasPremiumFromOrganizationAsync instead. This method is only used when feature flag 'PremiumAccessCacheCheck' is disabled.")]
Task<bool> HasPremiumFromOrganization(User user);
Task<string> GenerateSignInTokenAsync(User user, string purpose);

Expand Down
Loading
Loading