Skip to content

Conversation

@r-tome
Copy link
Contributor

@r-tome r-tome commented Dec 4, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-21411

📔 Objective

Introduce IHasPremiumAccessQuery to 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 PremiumAccessQuery feature flag.

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 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

@codecov
Copy link

codecov bot commented Dec 4, 2025

Codecov Report

❌ Patch coverage is 87.64706% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.81%. Comparing base (3de2f98) to head (97bf24e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...rFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs 86.95% 8 Missing and 4 partials ⚠️
src/Core/Services/Implementations/UserService.cs 14.28% 4 Missing and 2 partials ⚠️
...e/Billing/Premium/Queries/HasPremiumAccessQuery.cs 88.46% 2 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Contributor

github-actions bot commented Dec 4, 2025

Logo
Checkmarx One – Scan Summary & Details6932de75-76ad-4316-8294-6eaf1e99c293

New Issues (2)

Checkmarx found the following issues in this Pull Request

Severity Issue Source File / Package Checkmarx Insight
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1519
detailsMethod at line 1519 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
ID: dMGF5qNfAN72zlvQcA1MgbhHv%2Fc%3D
Attack Vector
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1395
detailsMethod at line 1395 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
ID: iOCFr11iI9znjDnv46yLfiS4aDY%3D
Attack Vector
Fixed Issues (2)

Great job! The following issues were fixed in this Pull Request

Severity Issue Source File / Package
MEDIUM CSRF /src/Api/AdminConsole/Public/Controllers/MembersController.cs: 207
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 300

@eliykat eliykat self-requested a review December 5, 2025 00:36
Copy link
Member

@eliykat eliykat left a 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?

@r-tome r-tome changed the base branch from main to auth/remove-2fa-user-from-premium-methods December 5, 2025 15:00
…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.
@r-tome r-tome changed the title Ac/pm 21411/refactor interface for premium status [PM-21411] Refactor interface for determining premium status and features Dec 5, 2025
… use 'PremiumAccessQuery' instead of 'PremiumAccessCacheCheck'. Adjust related XML documentation for clarity on premium access methods.
@eliykat eliykat self-requested a review December 5, 2025 21:11
}

// Has org premium if has premium access but not personal premium
return user.HasPremiumAccess && !user.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 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 ?

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 have updated the new implementation to match the logic in UserService.

HasPremiumFromOrganization is used through SyncController for billing logic.

@r-tome r-tome requested a review from cyprain-okeke December 10, 2025 12:06
cyprain-okeke
cyprain-okeke previously approved these changes Dec 10, 2025
…tatus

# Conflicts:
#	src/Core/Repositories/IUserRepository.cs
#	src/Infrastructure.EntityFramework/Repositories/UserRepository.cs
@r-tome r-tome dismissed stale reviews from cyprain-okeke and eliykat via 85e0e1a December 11, 2025 13:44
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.

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.

}

// 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;

{
Id = user.Id,
PersonalPremium = user.Premium,
OrganizationPremium = user.OrganizationUsers
Copy link
Contributor

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:

  1. For large user sets, this could load significant data
  2. 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
Copy link
Contributor

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]);
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).

/// </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>
/// 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.

/// <summary>
/// Retrieves premium access status for multiple users.
/// For internal use - consumers should use IHasPremiumAccessQuery instead.
/// </summary>
Copy link
Contributor

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());
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Test issue: Mock is throwing instead of returning null

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)
Copy link
Contributor

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;
Copy link
Contributor

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.

Copy link
Contributor

@mkincaid-bw mkincaid-bw left a 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]
Copy link
Contributor

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]);
END

Copy link
Contributor Author

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
Copy link
Contributor

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.

Copy link
Contributor Author

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.

- 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.
@r-tome r-tome requested a review from mkincaid-bw December 12, 2025 11:11
…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.
Copy link
Contributor

@mkincaid-bw mkincaid-bw left a comment

Choose a reason for hiding this comment

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

LGTM

@eliykat eliykat self-requested a review December 12, 2025 23:12
Copy link
Member

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants