diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs new file mode 100644 index 0000000000..0f5983158a --- /dev/null +++ b/src/Core/Auth/UserFeatures/PremiumAccess/IPremiumAccessQuery.cs @@ -0,0 +1,50 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.UserFeatures.PremiumAccess; + +/// +/// 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). +/// +/// +/// Note: This is different from checking User.Premium, which only indicates +/// personal subscription status. Use these methods to check actual premium feature access. +/// +/// +public interface IPremiumAccessQuery +{ + /// + /// 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. + /// + /// The user to check for premium access + /// True if user can access premium features; false otherwise + Task CanAccessPremiumAsync(User user); + + /// + /// 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. + /// + /// The user ID to check for premium access + /// Whether the user has a personal premium subscription + /// True if user can access premium features; false otherwise + Task CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium); + + /// + /// 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). + /// + /// The user ID to check for organization premium access + /// True if user has premium access through any organization; false otherwise + Task HasPremiumFromOrganizationAsync(Guid userId); + + /// + /// Checks if multiple users have access to premium features (optimized bulk operation). + /// Uses cached organization abilities and minimizes database queries. + /// + /// The users to check for premium access + /// Dictionary mapping user IDs to their premium access status (personal or through organization) + Task> CanAccessPremiumBulkAsync(IEnumerable users); +} + diff --git a/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs new file mode 100644 index 0000000000..9dc3045980 --- /dev/null +++ b/src/Core/Auth/UserFeatures/PremiumAccess/PremiumAccessQuery.cs @@ -0,0 +1,97 @@ +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.Auth.UserFeatures.PremiumAccess; + +/// +/// Query for checking premium access status for users using cached organization abilities. +/// +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 CanAccessPremiumAsync(User user) + { + return await CanAccessPremiumAsync(user.Id, user.Premium); + } + + public async Task CanAccessPremiumAsync(Guid userId, bool hasPersonalPremium) + { + if (hasPersonalPremium) + { + return true; + } + + return await HasPremiumFromOrganizationAsync(userId); + } + + public async Task 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> CanAccessPremiumBulkAsync(IEnumerable users) + { + var result = new Dictionary(); + 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; + } +} +