Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
Expand Up @@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
Task<IEnumerable<string>> GetOwnerEmailAddressesById(Guid organizationId);

/// <summary>
/// Gets the organizations that have a verified domain matching the user's email domain.
/// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user.
/// This requires that the organization has claimed the user's domain and the user is an organization member.
/// It excludes invited members.
/// </summary>
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ join od in dbContext.OrganizationDomains on ou.OrganizationId equals od.Organiza
where ou.UserId == userWithDomain.UserId &&
od.DomainName == userWithDomain.EmailDomain &&
od.VerifiedDate != null &&
o.Enabled == true
o.Enabled == true &&
ou.Status != OrganizationUserStatusType.Invited
Copy link
Contributor

Choose a reason for hiding this comment

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

Just thinking out loud here lol:

Iโ€™ve noticed we have a few cases where we exclude certain collection types or user types. I agree that we should do filtering in the database to leverage the query optimizer and reduce sending unnecessary data over the network. However, I wonder if we could configure the sproc's parameters to include only the types we want returned. That way, we can control the types from the app in the future, reduce the need for an extra deployment, and avoid potential backward compatibility issues.

That said, this does add upfront complexity to the sproc for a situation that might never happen.

Copy link
Member Author

@eliykat eliykat Jan 10, 2026

Choose a reason for hiding this comment

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

We do that in some sprocs, but here the status is closely tied to the purpose of the sproc: it's dealing with claimed accounts, and you can't claim an invited user's account. I would say this is a case where it should not be parameterized.

select o;

return await query.ToArrayAsync();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;

namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;

Expand All @@ -16,6 +17,7 @@ public IQueryable<OrganizationUser> Run(DatabaseContext dbContext)
var query = from ou in dbContext.OrganizationUsers
join u in dbContext.Users on ou.UserId equals u.Id
where ou.OrganizationId == _organizationId
&& ou.Status != OrganizationUserStatusType.Invited
&& dbContext.OrganizationDomains
.Any(od => od.OrganizationId == _organizationId &&
od.VerifiedDate != null &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ BEGIN
SELECT *
FROM [dbo].[OrganizationUserView]
WHERE [OrganizationId] = @OrganizationId
AND [Status] != 0 -- Exclude invited users
),
UserDomains AS (
SELECT U.[Id], U.[EmailDomain]
FROM [dbo].[UserEmailDomainView] U
WHERE EXISTS (
SELECT 1
FROM [dbo].[OrganizationDomainView] OD
FROM [dbo].[OrganizationDomainView] OD
WHERE OD.[OrganizationId] = @OrganizationId
AND OD.[VerifiedDate] IS NOT NULL
AND OD.[DomainName] = U.[EmailDomain]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ BEGIN
WHERE OD.[VerifiedDate] IS NOT NULL
AND CU.EmailDomain = OD.[DomainName]
AND O.[Enabled] = 1
AND OU.[Status] != 0
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be handy to have the status documented in a comment like you did in the other proc
https://contributing.bitwarden.com/contributing/code-style/sql/#comments-and-documentation

END
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;

namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationRepository;

public class GetByVerifiedUserEmailDomainAsyncTests
{
[Theory, DatabaseData]
public async Task GetByClaimedUserDomainAsync_WithVerifiedDomain_Success(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";

var user1 = await userRepository.CreateAsync(new User
{
Name = "Test User 1",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var user2 = await userRepository.CreateAsync(new User
{
Name = "Test User 2",
Email = $"test+{id}@x-{domainName}", // Different domain
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var user3 = await userRepository.CreateAsync(new User
{
Name = "Test User 2",
Email = $"test+{id}@{domainName}.example.com", // Different domain
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var organization = await organizationRepository.CreateTestOrganizationAsync();

var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetVerifiedDate();
organizationDomain.SetNextRunDate(12);
organizationDomain.SetJobRunCount();
await organizationDomainRepository.CreateAsync(organizationDomain);

await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user1);
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user2);
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user3);

var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id);
var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id);
var user3Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user3.Id);

Assert.NotEmpty(user1Response);
Assert.Equal(organization.Id, user1Response.First().Id);
Assert.Empty(user2Response);
Assert.Empty(user3Response);
}

[Theory, DatabaseData]
public async Task GetByVerifiedUserEmailDomainAsync_WithUnverifiedDomains_ReturnsEmpty(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";

var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var organization = await organizationRepository.CreateTestOrganizationAsync();

var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetNextRunDate(12);
organizationDomain.SetJobRunCount();
await organizationDomainRepository.CreateAsync(organizationDomain);

await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);

var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);

Assert.Empty(result);
}

[Theory, DatabaseData]
public async Task GetByVerifiedUserEmailDomainAsync_WithMultipleVerifiedDomains_ReturnsAllMatchingOrganizations(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";

var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var organization1 = await organizationRepository.CreateTestOrganizationAsync();
var organization2 = await organizationRepository.CreateTestOrganizationAsync();

var organizationDomain1 = new OrganizationDomain
{
OrganizationId = organization1.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain1.SetNextRunDate(12);
organizationDomain1.SetJobRunCount();
organizationDomain1.SetVerifiedDate();
await organizationDomainRepository.CreateAsync(organizationDomain1);

var organizationDomain2 = new OrganizationDomain
{
OrganizationId = organization2.Id,
DomainName = domainName,
Txt = "btw+67890",
};
organizationDomain2.SetNextRunDate(12);
organizationDomain2.SetJobRunCount();
organizationDomain2.SetVerifiedDate();
await organizationDomainRepository.CreateAsync(organizationDomain2);

await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization1, user);
await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization2, user);

var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);

Assert.Equal(2, result.Count);
Assert.Contains(result, org => org.Id == organization1.Id);
Assert.Contains(result, org => org.Id == organization2.Id);
}

[Theory, DatabaseData]
public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsEmpty(
IOrganizationRepository organizationRepository)
{
var nonExistentUserId = Guid.NewGuid();

var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(nonExistentUserId);

Assert.Empty(result);
}

/// <summary>
/// Tests an edge case where some invited users are created linked to a UserId.
/// This is defective behavior, but will take longer to fix - for now, we are defensive and expressly
/// exclude such users from the results without relying on the inner join only.
/// Invited-revoked users linked to a UserId remain intentionally unhandled for now as they have not caused
/// any issues to date and we want to minimize edge cases.
/// We will fix the underlying issue going forward: https://bitwarden.atlassian.net/browse/PM-22405
/// </summary>
[Theory, DatabaseData]
public async Task GetByVerifiedUserEmailDomainAsync_WithInvitedUserWithUserId_ReturnsEmpty(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";

var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var organization = await organizationRepository.CreateTestOrganizationAsync();

var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetVerifiedDate();
organizationDomain.SetNextRunDate(12);
organizationDomain.SetJobRunCount();
await organizationDomainRepository.CreateAsync(organizationDomain);

// Create invited user with matching email domain but UserId set (edge case)
await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Email = user.Email,
Status = OrganizationUserStatusType.Invited,
});

var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);

// Invited users should be excluded even if they have UserId set
Assert.Empty(result);
}

[Theory, DatabaseData]
public async Task GetByVerifiedUserEmailDomainAsync_WithAcceptedUser_ReturnsOrganization(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";

var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var organization = await organizationRepository.CreateTestOrganizationAsync();

var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetVerifiedDate();
organizationDomain.SetNextRunDate(12);
organizationDomain.SetJobRunCount();
await organizationDomainRepository.CreateAsync(organizationDomain);

await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);

var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);

Assert.NotEmpty(result);
Assert.Equal(organization.Id, result.First().Id);
}

[Theory, DatabaseData]
public async Task GetByVerifiedUserEmailDomainAsync_WithRevokedUser_ReturnsOrganization(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationDomainRepository organizationDomainRepository)
{
var id = Guid.NewGuid();
var domainName = $"{id}.example.com";

var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{id}@{domainName}",
ApiKey = "TEST",
SecurityStamp = "stamp",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 1,
KdfMemory = 2,
KdfParallelism = 3
});

var organization = await organizationRepository.CreateTestOrganizationAsync();

var organizationDomain = new OrganizationDomain
{
OrganizationId = organization.Id,
DomainName = domainName,
Txt = "btw+12345",
};
organizationDomain.SetVerifiedDate();
organizationDomain.SetNextRunDate(12);
organizationDomain.SetJobRunCount();
await organizationDomainRepository.CreateAsync(organizationDomain);

await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user);

var result = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user.Id);

Assert.NotEmpty(result);
Assert.Equal(organization.Id, result.First().Id);
}
}
Loading
Loading