Skip to content
Merged
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
92f7a77
Add new feature flag for Members Get Endpoint Optimization
r-tome Jun 2, 2025
08688d6
Add a new version of OrganizationUser_ReadByOrganizationIdWithClaimedโ€ฆ
r-tome Jun 2, 2025
3fa2797
Add stored procedure OrganizationUserUserDetails_ReadByOrganizationIdโ€ฆ
r-tome Jun 2, 2025
5fdad60
Add the sql migration script to add the new stored procedures
r-tome Jun 2, 2025
b40e813
Introduce GetManyDetailsByOrganizationAsync_vNext and GetManyByOrganiโ€ฆ
r-tome Jun 2, 2025
0579ec4
Updated GetOrganizationUsersClaimedStatusQuery to use an optimized quโ€ฆ
r-tome Jun 2, 2025
516509a
Updated OrganizationUserUserDetailsQuery to use optimized queries wheโ€ฆ
r-tome Jun 2, 2025
b9e2f5f
Add integration tests for GetManyDetailsByOrganizationAsync_vNext
r-tome Jun 2, 2025
79b0041
Add integration tests for GetManyByOrganizationWithClaimedDomainsAsynโ€ฆ
r-tome Jun 2, 2025
baf42f7
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jun 9, 2025
c1534d1
Optimize performance by conditionally setting permissions only for Cuโ€ฆ
r-tome Jun 9, 2025
fbfd101
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jun 17, 2025
72b41c8
Create UserEmailDomainView to extract email domains from users' emailโ€ฆ
r-tome Jun 17, 2025
26c288b
Create stored procedure Organization_ReadByClaimedUserEmailDomain_V2 โ€ฆ
r-tome Jun 17, 2025
1e0c685
Add GetByVerifiedUserEmailDomainAsync_vNext method to IOrganizationReโ€ฆ
r-tome Jun 17, 2025
0f70876
Refactor OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2 sโ€ฆ
r-tome Jun 17, 2025
2485331
Enhance IOrganizationUserRepository with detailed documentation for Gโ€ฆ
r-tome Jun 17, 2025
d6c0e8d
Fix missing newline at the end of Organization_ReadByClaimedUserEmailโ€ฆ
r-tome Jun 17, 2025
df9a198
Update the database migration script to include UserEmailDomainView
r-tome Jun 17, 2025
9ecf758
Bumped the date on the migration script
r-tome Jun 17, 2025
2728467
Remove GetByVerifiedUserEmailDomainAsync_vNext method and its stored โ€ฆ
r-tome Jun 17, 2025
405d8b2
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jun 19, 2025
cfa0cb1
Refactor UserEmailDomainView index creation to check for existence beโ€ฆ
r-tome Jun 19, 2025
8323e53
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jul 7, 2025
38214b4
Update OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2 to โ€ฆ
r-tome Jul 7, 2025
0d34e87
Remove creation of unique clustered index from UserEmailDomainView anโ€ฆ
r-tome Jul 7, 2025
046794c
Update indexes and sproc
r-tome Jul 9, 2025
2a559ad
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jul 16, 2025
e2d489f
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jul 21, 2025
a69064f
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jul 22, 2025
8f8d3f2
Fix index name when checking if it already exists
r-tome Jul 22, 2025
234c1b6
Bump up date on migration script
r-tome Jul 22, 2025
bcf4eb2
Merge branch 'main' into ac/pm-21031/optimize-get-members-endpoint-peโ€ฆ
r-tome Jul 23, 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
@@ -8,13 +8,16 @@ public class GetOrganizationUsersClaimedStatusQuery : IGetOrganizationUsersClaim
{
private readonly IApplicationCacheService _applicationCacheService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IFeatureService _featureService;

public GetOrganizationUsersClaimedStatusQuery(
IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository)
IOrganizationUserRepository organizationUserRepository,
IFeatureService featureService)
{
_applicationCacheService = applicationCacheService;
_organizationUserRepository = organizationUserRepository;
_featureService = featureService;
}

public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds)
@@ -27,7 +30,9 @@ public async Task<IDictionary<Guid, bool>> GetUsersOrganizationClaimedStatusAsyn
if (organizationAbility is { Enabled: true, UseOrganizationDomains: true })
{
// Get all organization users with claimed domains by the organization
var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
var organizationUsersWithClaimedDomain = _featureService.IsEnabled(FeatureFlagKeys.MembersGetEndpointOptimization)
? await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync_vNext(organizationId)
: await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
Comment on lines +33 to +35
Copy link
Contributor

Choose a reason for hiding this comment

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

๐ŸŒฑ Run this in parallel and wait for them before L38?

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 thought about it but the organizationAbility value comes from the cache, so it's quick to access. If if (organizationAbility is { Enabled: true, UseOrganizationDomains: true }) returns false there's no need to query the database.

Copy link
Contributor

Choose a reason for hiding this comment

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

Cache may have issues for that lookup https://bitwarden.atlassian.net/wiki/x/iYDibg

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thats an issue on the cache mechanism, right? If so, that seems to be out of scope here.

Copy link
Contributor

Choose a reason for hiding this comment

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


// Create a dictionary with the OrganizationUserId and a boolean indicating if the user is claimed by the organization
return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId));
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
๏ปฟusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
๏ปฟusing Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@@ -43,9 +44,12 @@ public async Task<IEnumerable<OrganizationUserUserDetails>> GetOrganizationUserU
return organizationUsers
.Select(o =>
{
var userPermissions = o.GetPermissions();

o.Permissions = CoreHelpers.ClassToJsonData(userPermissions);
// Only set permissions for Custom user types for performance optimization
if (o.Type == OrganizationUserType.Custom)
{
var userPermissions = o.GetPermissions();
o.Permissions = CoreHelpers.ClassToJsonData(userPermissions);
}

return o;
});
@@ -59,6 +63,11 @@ public async Task<IEnumerable<OrganizationUserUserDetails>> GetOrganizationUserU
/// <returns>List of OrganizationUserUserDetails</returns>
public async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> Get(OrganizationUserUserDetailsQueryRequest request)
{
if (_featureService.IsEnabled(FeatureFlagKeys.MembersGetEndpointOptimization))
{
return await Get_vNext(request);
}

var organizationUsers = await GetOrganizationUserUserDetails(request);

var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id);
@@ -77,6 +86,11 @@ public async Task<IEnumerable<OrganizationUserUserDetails>> GetOrganizationUserU
/// <returns>List of OrganizationUserUserDetails</returns>
public async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request)
{
if (_featureService.IsEnabled(FeatureFlagKeys.MembersGetEndpointOptimization))
{
return await GetAccountRecoveryEnrolledUsers_vNext(request);
}

var organizationUsers = (await GetOrganizationUserUserDetails(request))
.Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey));

@@ -88,4 +102,65 @@ public async Task<IEnumerable<OrganizationUserUserDetails>> GetOrganizationUserU
return responses;
}

private async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> Get_vNext(OrganizationUserUserDetailsQueryRequest request)
{
var organizationUsers = await _organizationUserRepository
.GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections);

var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id));

await Task.WhenAll(twoFactorTask, claimedStatusTask);

var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled);
var organizationUsersClaimedStatus = claimedStatusTask.Result;
var responses = organizationUsers.Select(organizationUserDetails =>
{
// Only set permissions for Custom user types for performance optimization
if (organizationUserDetails.Type == OrganizationUserType.Custom)
{
var organizationUserPermissions = organizationUserDetails.GetPermissions();
organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions);
}

var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id];
var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id];

return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization);
});

return responses;
}

private async Task<IEnumerable<(OrganizationUserUserDetails OrgUser, bool TwoFactorEnabled, bool ClaimedByOrganization)>> GetAccountRecoveryEnrolledUsers_vNext(OrganizationUserUserDetailsQueryRequest request)
{
var organizationUsers = (await _organizationUserRepository
.GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections))
.Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey))
.ToArray();

var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id));

await Task.WhenAll(twoFactorTask, claimedStatusTask);

var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled);
var organizationUsersClaimedStatus = claimedStatusTask.Result;
var responses = organizationUsers.Select(organizationUserDetails =>
{
// Only set permissions for Custom user types for performance optimization
if (organizationUserDetails.Type == OrganizationUserType.Custom)
{
var organizationUserPermissions = organizationUserDetails.GetPermissions();
organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions);
}

var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id];
var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id];

return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization);
});

return responses;
}
}
Original file line number Diff line number Diff line change
@@ -36,6 +36,12 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
/// <param name="includeCollections">Whether to include collections</param>
/// <returns>A list of OrganizationUserUserDetails</returns>
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
/// <inheritdoc cref="GetManyDetailsByOrganizationAsync"/>
/// <remarks>
/// This method is optimized for performance.
/// Reduces database round trips by fetching all data in fewer queries.
/// </remarks>
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null);
Task<OrganizationUserOrganizationDetails?> GetDetailsByUserAsync(Guid userId, Guid organizationId,
@@ -70,7 +76,10 @@ UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId,
/// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains.
/// </summary>
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId);

/// <summary>
/// Optimized version of <see cref="GetManyByOrganizationWithClaimedDomainsAsync"/> with better performance.
/// </summary>
Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId);
Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds);

/// <summary>
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
@@ -115,6 +115,7 @@ public static class FeatureFlagKeys
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string ImportAsyncRefactor = "pm-22583-refactor-import-async";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string MembersGetEndpointOptimization = "pm-21031-members-get-endpoint-optimization";

/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
Original file line number Diff line number Diff line change
@@ -268,6 +268,68 @@ public async Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrga
}
}

public async Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups, bool includeCollections)
{
using (var connection = new SqlConnection(ConnectionString))
{
// Use a single call that returns multiple result sets
var results = await connection.QueryMultipleAsync(
"[dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2]",
new
{
OrganizationId = organizationId,
IncludeGroups = includeGroups,
IncludeCollections = includeCollections
},
commandType: CommandType.StoredProcedure);

// Read the user details (first result set)
var users = (await results.ReadAsync<OrganizationUserUserDetails>()).ToList();

// Read group associations (second result set, if requested)
Dictionary<Guid, List<Guid>>? userGroupMap = null;
if (includeGroups)
{
var groupUsers = await results.ReadAsync<GroupUser>();
userGroupMap = groupUsers
.GroupBy(gu => gu.OrganizationUserId)
.ToDictionary(g => g.Key, g => g.Select(gu => gu.GroupId).ToList());
}

// Read collection associations (third result set, if requested)
Dictionary<Guid, List<CollectionAccessSelection>>? userCollectionMap = null;
if (includeCollections)
{
var collectionUsers = await results.ReadAsync<CollectionUser>();
userCollectionMap = collectionUsers
.GroupBy(cu => cu.OrganizationUserId)
.ToDictionary(g => g.Key, g => g.Select(cu => new CollectionAccessSelection
{
Id = cu.CollectionId,
ReadOnly = cu.ReadOnly,
HidePasswords = cu.HidePasswords,
Manage = cu.Manage
}).ToList());
}

// Map the associations to users
foreach (var user in users)
{
if (userGroupMap != null)
{
user.Groups = userGroupMap.GetValueOrDefault(user.Id, new List<Guid>());
}

if (userCollectionMap != null)
{
user.Collections = userCollectionMap.GetValueOrDefault(user.Id, new List<CollectionAccessSelection>());
}
}

return users;
}
}

public async Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null)
{
@@ -558,6 +620,19 @@ public async Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaime
}
}

public async Task<ICollection<OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<OrganizationUser>(
$"[{Schema}].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);

return results.ToList();
}
}

public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
{
await using var connection = new SqlConnection(ConnectionString);
Original file line number Diff line number Diff line change
@@ -404,6 +404,56 @@ join c in dbContext.Collections on cu.CollectionId equals c.Id
}
}

public async Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync_vNext(
Guid organizationId, bool includeGroups, bool includeCollections)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);

var query = from ou in dbContext.OrganizationUsers
where ou.OrganizationId == organizationId
select new OrganizationUserUserDetails
{
Id = ou.Id,
UserId = ou.UserId,
OrganizationId = ou.OrganizationId,
Name = ou.User.Name,
Email = ou.User.Email ?? ou.Email,
AvatarColor = ou.User.AvatarColor,
TwoFactorProviders = ou.User.TwoFactorProviders,
Premium = ou.User.Premium,
Status = ou.Status,
Type = ou.Type,
ExternalId = ou.ExternalId,
SsoExternalId = ou.User.SsoUsers
.Where(su => su.OrganizationId == ou.OrganizationId)
.Select(su => su.ExternalId)
.FirstOrDefault(),
Permissions = ou.Permissions,
ResetPasswordKey = ou.ResetPasswordKey,
UsesKeyConnector = ou.User != null && ou.User.UsesKeyConnector,
AccessSecretsManager = ou.AccessSecretsManager,
HasMasterPassword = ou.User != null && !string.IsNullOrWhiteSpace(ou.User.MasterPassword),

// Project directly from navigation properties with conditional loading
Groups = includeGroups
? ou.GroupUsers.Select(gu => gu.GroupId).ToList()
: new List<Guid>(),

Collections = includeCollections
? ou.CollectionUsers.Select(cu => new CollectionAccessSelection
{
Id = cu.CollectionId,
ReadOnly = cu.ReadOnly,
HidePasswords = cu.HidePasswords,
Manage = cu.Manage
}).ToList()
: new List<CollectionAccessSelection>()
};

return await query.ToListAsync();
}

public async Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null)
{
@@ -732,6 +782,12 @@ public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(
}
}

public async Task<ICollection<Core.Entities.OrganizationUser>> GetManyByOrganizationWithClaimedDomainsAsync_vNext(Guid organizationId)
{
// No EF optimization is required for this query
return await GetManyByOrganizationWithClaimedDomainsAsync(organizationId);
}

public async Task RevokeManyByIdAsync(IEnumerable<Guid> organizationUserIds)
{
using var scope = ServiceScopeFactory.CreateScope();
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
๏ปฟCREATE PROCEDURE [dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2]
@OrganizationId UNIQUEIDENTIFIER,
@IncludeGroups BIT = 0,
@IncludeCollections BIT = 0
AS
BEGIN
SET NOCOUNT ON

-- Result Set 1: User Details (always returned)
SELECT *
FROM [dbo].[OrganizationUserUserDetailsView]
WHERE OrganizationId = @OrganizationId

-- Result Set 2: Group associations (if requested)
IF @IncludeGroups = 1
BEGIN
SELECT gu.*
FROM [dbo].[GroupUser] gu
INNER JOIN [dbo].[OrganizationUser] ou ON gu.OrganizationUserId = ou.Id
WHERE ou.OrganizationId = @OrganizationId
END

-- Result Set 3: Collection associations (if requested)
IF @IncludeCollections = 1
BEGIN
SELECT cu.*
FROM [dbo].[CollectionUser] cu
INNER JOIN [dbo].[OrganizationUser] ou ON cu.OrganizationUserId = ou.Id
WHERE ou.OrganizationId = @OrganizationId
END
END
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
๏ปฟCREATE PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;

WITH OrgUsers AS (
SELECT *
FROM [dbo].[OrganizationUserView]
WHERE [OrganizationId] = @OrganizationId
),
UserDomains AS (
SELECT U.[Id], U.[EmailDomain]
FROM [dbo].[UserEmailDomainView] U
WHERE EXISTS (
SELECT 1
FROM [dbo].[OrganizationDomainView] OD
WHERE OD.[OrganizationId] = @OrganizationId
AND OD.[VerifiedDate] IS NOT NULL
AND OD.[DomainName] = U.[EmailDomain]
)
)
SELECT OU.*
FROM OrgUsers OU
JOIN UserDomains UD ON OU.[UserId] = UD.[Id]
OPTION (RECOMPILE);
END
8 changes: 7 additions & 1 deletion src/Sql/dbo/Tables/OrganizationDomain.sql
Original file line number Diff line number Diff line change
@@ -25,5 +25,11 @@ GO

CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_DomainNameVerifiedDateOrganizationId]
ON [dbo].[OrganizationDomain] ([DomainName],[VerifiedDate])
INCLUDE ([OrganizationId])
INCLUDE ([OrganizationId]);
GO

CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationId_VerifiedDate]
ON [dbo].[OrganizationDomain] ([OrganizationId], [VerifiedDate])
INCLUDE ([DomainName])
WHERE [VerifiedDate] IS NOT NULL;
GO
7 changes: 7 additions & 0 deletions src/Sql/dbo/Tables/OrganizationUser.sql
Original file line number Diff line number Diff line change
@@ -27,3 +27,10 @@ GO
CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId]
ON [dbo].[OrganizationUser]([OrganizationId] ASC);

GO

CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId]
ON [dbo].[OrganizationUser] ([OrganizationId], [UserId])
INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate],
[RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager]);
GO
4 changes: 4 additions & 0 deletions src/Sql/dbo/Tables/User.sql
Original file line number Diff line number Diff line change
@@ -54,3 +54,7 @@ GO
CREATE NONCLUSTERED INDEX [IX_User_Premium_PremiumExpirationDate_RenewalReminderDate]
ON [dbo].[User]([Premium] ASC, [PremiumExpirationDate] ASC, [RenewalReminderDate] ASC);

GO
CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain]
ON [dbo].[User]([Id] ASC, [Email] ASC);

10 changes: 10 additions & 0 deletions src/Sql/dbo/Views/UserEmailDomainView.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE VIEW [dbo].[UserEmailDomainView]
Copy link
Contributor

Choose a reason for hiding this comment

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

๐Ÿ’ญ Should this use the same ...Details naming we have for hybrid objects? I think it should, since it doesn't represent a single table. It's only used in a join though, at least for now, so it feels open for discussion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This represents columns from just the User table (no JOINs), whereas other Details views always JOIN multiple tables together

AS
SELECT
Id,
Email,
SUBSTRING(Email, CHARINDEX('@', Email) + 1, LEN(Email)) AS EmailDomain
FROM dbo.[User]
WHERE Email IS NOT NULL
AND CHARINDEX('@', Email) > 0
GO

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
CREATE OR ALTER VIEW [dbo].[UserEmailDomainView]
AS
SELECT
Id,
Email,
SUBSTRING(Email, CHARINDEX('@', Email) + 1, LEN(Email)) AS EmailDomain
FROM dbo.[User]
WHERE Email IS NOT NULL
AND CHARINDEX('@', Email) > 0
GO

-- Index on OrganizationUser for efficient filtering
IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationUser_OrganizationId_UserId')
BEGIN
CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId]
ON [dbo].[OrganizationUser] ([OrganizationId], [UserId])
INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate],
[RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager])
END
GO

IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_User_Id_EmailDomain')
BEGIN
CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain]
ON [dbo].[User] ([Id], [Email])
END
GO

IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_OrganizationDomain_OrganizationId_VerifiedDate')
BEGIN
CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationId_VerifiedDate]
ON [dbo].[OrganizationDomain] ([OrganizationId], [VerifiedDate])
INCLUDE ([DomainName])
WHERE [VerifiedDate] IS NOT NULL
END
GO

CREATE OR ALTER PROCEDURE [dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2]
@OrganizationId UNIQUEIDENTIFIER,
@IncludeGroups BIT = 0,
@IncludeCollections BIT = 0
AS
BEGIN
SET NOCOUNT ON

-- Result Set 1: User Details (always returned)
SELECT *
FROM [dbo].[OrganizationUserUserDetailsView]
WHERE OrganizationId = @OrganizationId

-- Result Set 2: Group associations (if requested)
IF @IncludeGroups = 1
BEGIN
SELECT gu.*
FROM [dbo].[GroupUser] gu
INNER JOIN [dbo].[OrganizationUser] ou ON gu.OrganizationUserId = ou.Id
WHERE ou.OrganizationId = @OrganizationId
END

-- Result Set 3: Collection associations (if requested)
IF @IncludeCollections = 1
BEGIN
SELECT cu.*
FROM [dbo].[CollectionUser] cu
INNER JOIN [dbo].[OrganizationUser] ou ON cu.OrganizationUserId = ou.Id
WHERE ou.OrganizationId = @OrganizationId
END
END
GO

CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;

WITH OrgUsers AS (
SELECT *
FROM [dbo].[OrganizationUserView]
WHERE [OrganizationId] = @OrganizationId
),
UserDomains AS (
SELECT U.[Id], U.[EmailDomain]
FROM [dbo].[UserEmailDomainView] U
WHERE EXISTS (
SELECT 1
FROM [dbo].[OrganizationDomainView] OD
WHERE OD.[OrganizationId] = @OrganizationId
AND OD.[VerifiedDate] IS NOT NULL
AND OD.[DomainName] = U.[EmailDomain]
)
)
SELECT OU.*
FROM OrgUsers OU
JOIN UserDomains UD ON OU.[UserId] = UD.[Id]
OPTION (RECOMPILE);
END
GO

Unchanged files with check annotations Beta

}
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid organizationId, Guid id)

Check warning on line 51 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
}
[HttpGet("")]
public async Task<IActionResult> Get(

Check warning on line 62 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
Guid organizationId,
[FromQuery] GetUsersQueryParamModel model)
{
}
[HttpPost("")]
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)

Check warning on line 78 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var orgUser = await _postUserCommand.PostUserAsync(organizationId, model);
var scimUserResponseModel = new ScimUserResponseModel(orgUser);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)

Check warning on line 86 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
}
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)

Check warning on line 113 in bitwarden_license/src/Scim/Controllers/v2/UsersController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
await _patchUserCommand.PatchUserAsync(organizationId, id, model);
return new NoContentResult();
}
[HttpPost("webhook")]
public async Task<IActionResult> PostWebhook([FromQuery, Required] string key,

Check warning on line 46 in src/Billing/Controllers/FreshdeskController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
[FromBody, Required] FreshdeskWebhookModel model)
{
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
}
[HttpPost("webhook-onyx-ai")]
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,

Check warning on line 146 in src/Billing/Controllers/FreshdeskController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
{
// ensure that the key is from Freshdesk
[HttpPost("webhook")]
public async Task<IActionResult> PostWebhook([FromHeader(Name = "Authorization")] string key,

Check warning on line 52 in src/Billing/Controllers/FreshsalesController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
[FromBody] CustomWebhookRequestModel request,
CancellationToken cancellationToken)
{
}
[HttpPost("ipn")]
public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key)

Check warning on line 60 in src/Billing/Controllers/BitPayController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey))
{
return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
}
public Tuple<Guid?, Guid?, Guid?> GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice)

Check warning on line 206 in src/Billing/Controllers/BitPayController.cs

GitHub Actions / Quality scan

ModelState.IsValid should be checked in controller actions. (https://rules.sonarsource.com/csharp/RSPEC-6967)
{
Guid? orgId = null;
Guid? userId = null;