-
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: main
Are you sure you want to change the base?
Conversation
…on for checking user premium access status
…d feature flag for premium access checks. Enhanced user premium status retrieval logic and improved handling of user details based on feature flag state.
…ng users to new methods in IPremiumAccessQuery for premium access checks.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6688 +/- ##
==========================================
+ Coverage 53.86% 57.81% +3.94%
==========================================
Files 1917 1924 +7
Lines 85126 85373 +247
Branches 7620 7643 +23
==========================================
+ Hits 45853 49355 +3502
+ Misses 37508 34174 -3334
- Partials 1765 1844 +79 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
New Issues (2)Checkmarx found the following issues in this Pull Request
|
eliykat
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work. I just noticed that User has a PremiumExpirationDate. Do we need to check if premium has expired? Or is the premium column automatically updated when the expiration date passes?
src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs
Outdated
Show resolved
Hide resolved
src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs
Outdated
Show resolved
Hide resolved
src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
Outdated
Show resolved
Hide resolved
src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
Outdated
Show resolved
Hide resolved
…improved premium access checks and user detail handling. Removed obsolete feature service dependency and enhanced test coverage for new functionality.
…rloaded CanAccessPremiumAsync method. Update related methods to streamline premium access checks using the User object directly. Enhance test coverage by removing obsolete tests and ensuring proper functionality with the new method signatures.
…rDetails and User classes to clarify its usage and limitations regarding personal and organizational premium access.
…arameter with Guid for user ID in CanAccessPremiumAsync methods. Update related methods and tests to streamline premium access checks and improve clarity in method signatures.
… use 'PremiumAccessQuery' instead of 'PremiumAccessCacheCheck'. Adjust related XML documentation for clarity on premium access methods.
| } | ||
|
|
||
| // Has org premium if has premium access but not personal premium | ||
| return user.HasPremiumAccess && !user.Premium; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is subtly different to the current implementation in UserService.HasPremiumFromOrganization: that current logic will return false if the user is not a part of any orgs, but will not return false if they have both personal premium and premium from an org. That doesn't seem right either, but it's unclear what the intent is.
That said, I have no idea why we need this logic: it's only synced to clients but doesn't seem to be used there either. I would be interested to know if we could remove it.
Any ideas @amorask-bitwarden ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have updated the new implementation to match the logic in UserService.
HasPremiumFromOrganization is used through SyncController for billing logic.
src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs
Outdated
Show resolved
Hide resolved
…tatus # Conflicts: # src/Core/Repositories/IUserRepository.cs # src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
| var distinctUserIds = userIds.Distinct().ToList(); | ||
| var usersWithPremium = await _userRepository.GetPremiumAccessByIdsAsync(distinctUserIds); | ||
|
|
||
| if (usersWithPremium.Count() != distinctUserIds.Count) |
There was a problem hiding this comment.
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.
| var userId = user.GetUserId(); | ||
| if (userId.HasValue) | ||
| { | ||
| var hasTwoFactor = twoFactorResults.FirstOrDefault(res => res.userId == userId.Value).twoFactorIsEnabled; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| } | ||
|
|
||
| // User has providers. If they're in the premium check map, verify premium status | ||
| var twoFactorIsEnabled = !premiumStatusMap.TryGetValue(user.Id, out var hasPremium) || hasPremium; |
There was a problem hiding this comment.
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;| { | ||
| Id = user.Id, | ||
| PersonalPremium = user.Premium, | ||
| OrganizationPremium = user.OrganizationUsers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 N+1 query concern with EF implementation
The Entity Framework implementation eagerly loads organization data for all users using Include().ThenInclude(). This is good! However, be aware that:
- For large user sets, this could load significant data
- The Dapper version uses a database view with optimized query
Consider adding a comment documenting this difference, or potentially adding pagination if this method could be called with large user ID sets.
// Note: This eagerly loads organization data. For large user sets (>1000),
// consider using the Dapper implementation or adding pagination.
OrganizationPremium = user.OrganizationUsers| FROM [dbo].[OrganizationUser] OU | ||
| INNER JOIN [dbo].[Organization] O ON O.[Id] = OU.[OrganizationId] | ||
| WHERE OU.[UserId] = U.[Id] | ||
| AND O.[UsersGetPremium] = 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Good: Filtered index will help performance
The migration script adds UsersGetPremium to the IX_Organization_Enabled index, which should help optimize this EXISTS subquery. The view looks efficient.
One minor suggestion: Consider adding a comment in the view definition explaining that this view is used for bulk premium access checks, to help future maintainers understand its purpose.
| return result; | ||
| } | ||
|
|
||
| var users = await _userRepository.GetManyAsync([.. userIds]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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).
| /// </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); |
There was a problem hiding this comment.
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> | ||
| /// Indicates whether the user has premium access from any source (personal subscription or organization). | ||
| /// </summary> | ||
| public bool HasPremiumAccess => PersonalPremium || OrganizationPremium; |
There was a problem hiding this comment.
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.
| /// <summary> | ||
| /// Retrieves premium access status for multiple users. | ||
| /// For internal use - consumers should use IHasPremiumAccessQuery instead. | ||
| /// </summary> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Good: Clear documentation about internal use
The comment "For internal use - consumers should use IHasPremiumAccessQuery instead" is helpful. This makes it clear that these repository methods are implementation details.
One suggestion: Consider adding [EditorBrowsable(EditorBrowsableState.Advanced)] to these methods to hide them from IntelliSense in most scenarios:
using System.ComponentModel;
[EditorBrowsable(EditorBrowsableState.Advanced)]
Task<IEnumerable<UserPremiumAccess>> GetPremiumAccessByIdsAsync(IEnumerable<Guid> ids);This would nudge developers toward using the query interface.
| // Arrange | ||
| sutProvider.GetDependency<IUserRepository>() | ||
| .GetPremiumAccessAsync(userId) | ||
| .Returns<UserPremiumAccess>(_ => throw new NotFoundException()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is configuring the mock incorrectly:
sutProvider.GetDependency<IUserRepository>()
.GetPremiumAccessAsync(userId)
.Returns<UserPremiumAccess>(_ => throw new NotFoundException());The implementation expects GetPremiumAccessAsync to return null, which the query then checks and throws NotFoundException. But this test has the repository itself throw.
This should be:
sutProvider.GetDependency<IUserRepository>()
.GetPremiumAccessAsync(userId)
.Returns((UserPremiumAccess?)null);The same issue exists in line 177 for HasPremiumFromOrganizationAsync test.
| GO | ||
|
|
||
| CREATE NONCLUSTERED INDEX [IX_Organization_Enabled] | ||
| ON [dbo].[Organization]([Id] ASC, [Enabled] ASC) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Good: Index optimization
Adding UsersGetPremium to the INCLUDE clause of the existing index is smart. This allows the view's EXISTS query to be a covering query without needing to hit the table data pages.
One consideration: Make sure this doesn't make the index too large. The index now includes UseTotp and UsersGetPremium. Monitor index size and fragmentation after deployment.
| using Bit.Core.Billing.Models; | ||
| using Bit.Core.Billing.Models.Business; | ||
| using Bit.Core.Billing.Models.Sales; | ||
| using Bit.Core.Billing.Premium.Queries; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ℹ️ Context: UserService integration
Based on earlier PR comments, this will eventually be updated to use IHasPremiumAccessQuery for the HasPremiumFromOrganization method. Good that it's being done incrementally.
Make sure to add a TODO or tracking issue for the follow-up work.
mkincaid-bw
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question about performance.
| END | ||
| GO | ||
|
|
||
| CREATE NONCLUSTERED INDEX [IX_Organization_Enabled] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🌱 In this case, dropping and re-creating this index is likely okay since the Organization table is relatively small. For larger tables, it's better to create the index with DROP_EXISTING = ON. This allows SQL server to use the existing index to build the new one, and also keeps the existing one in place so it can be used for queries until the new one is ready.
IF EXISTS (
SELECT * FROM sys.indexes
WHERE name = 'IX_Organization_Enabled'
AND object_id = OBJECT_ID('[dbo].[Organization]')
)
BEGIN
CREATE NONCLUSTERED INDEX [IX_Organization_Enabled]
ON [dbo].[Organization]([Id] ASC, [Enabled] ASC)
INCLUDE ([UseTotp], [UsersGetPremium])
WITH (DROP_EXISTING = ON);
END
ELSE
BEGIN
CREATE NONCLUSTERED INDEX [IX_Organization_Enabled]
ON [dbo].[Organization]([Id] ASC, [Enabled] ASC)
INCLUDE ([UseTotp], [UsersGetPremium]);
ENDThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good to know that! Updated
| GO | ||
|
|
||
| CREATE OR ALTER PROCEDURE [dbo].[User_ReadPremiumAccessByIds] | ||
| @Ids [dbo].[GuidIdArray] READONLY |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ How many users do you anticipate being passed into this proc? I tested your view with different amounts of random users (100 users, 1,000 users, 10,000 users, etc). With 10,000 users, this query took almost 10 seconds against a prod backup DB.
The correlated subquery with the EXISTS statement won't scale well. If performance is a concern, perhaps consider re-writing the view with joins instead.
CREATE OR ALTER VIEW [dbo].[UserPremiumAccessView]
AS
SELECT
U.[Id],
U.[Premium] AS [PersonalPremium],
CAST(
MAX(CASE
WHEN O.[Id] IS NOT NULL THEN 1
ELSE 0
END
)
AS BIT
) AS [OrganizationPremium]
FROM
[dbo].[User] U
LEFT JOIN
[dbo].[OrganizationUser] OU ON OU.[UserId] = U.[Id]
LEFT JOIN
[dbo].[Organization] O ON O.[Id] = OU.[OrganizationId]
AND O.[UsersGetPremium] = 1
AND O.[Enabled] = 1
GROUP BY
U.[Id], U.[Premium];I had Claude convert the query so you should verify that the logic is still correct, but this query scales much better over large data sets.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How many users do you anticipate being passed into this proc?
Most common use case is one user, the sproc will be called for every sync operation.
For multiple users it'll be called when organization admins view the members page. Definitely not as often as the individual sync operations but then it depends on the organization size.
Yes, performance is a concern so thanks for checking that! Your performance testing with 10k users confirms we need the JOIN-based approach
I updated that query and also updated the integration tests to make sure they all pass.
…roved performance and clarity
…Async instead of throwing NotFoundException
- Implement tests for GetPremiumAccessAsync to cover various user and organization premium access combinations. - Validate behavior when users belong to multiple organizations, including cases with and without premium access. - Update email generation for user creation to ensure uniqueness without specific prefixes. - Enhance assertions to verify expected premium access results across different test cases.
… in index properties
…QL, PostgreSQL, and SQLite - Introduced new migration files to create the OrganizationUsersGetPremiumIndex. - Updated the DatabaseContextModelSnapshot to include UsersGetPremium in index properties for all database types. - Ensured consistency in index creation across different database implementations.
mkincaid-bw
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just double checking that this empty migration is OK/intentional?
(same for Sqlite)


🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-21411
📔 Objective
Introduce
IHasPremiumAccessQueryto centralize premium-access checks and make the distinction clearer between having a personal premium subscription (User.Premium) and actually having access to premium features (personal subscription or org membership).This new query uses a new database view (
UserPremiumAccessView) and a new stored procedure (User_ReadPremiumAccessByIds) to efficiently check premium status in bulk.The implementation is gated behind the
PremiumAccessQueryfeature flag.⏰ Reminders before review
🦮 Reviewer guidelines
:+1:) or similar for great changes:memo:) or ℹ️ (:information_source:) for notes or general info:question:) for questions:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion:art:) for suggestions / improvements:x:) or:warning:) for more significant problems or concerns needing attention:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt:pick:) for minor or nitpick changes