-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[PM-21411] Refactor interface for determining premium status and features #6688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: auth/remove-2fa-user-from-premium-methods
Are you sure you want to change the base?
Changes from 7 commits
f83a790
d30f3bd
377ae31
683a6cb
08e1413
e494a10
0cdadd1
192052b
2d31237
123f579
57252a7
bf0cc7a
65941d4
2a966e3
96a3615
516824a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
|
||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
||
| } | ||
|
|
||
eliykat marked this conversation as resolved.
Show resolved
Hide resolved
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
|
|
@@ -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) | ||
| { | ||
|
|
@@ -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; | ||
|
|
@@ -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> | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
Uh oh!
There was an error while loading. Please reload this page.