diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs index 694b44dd7817..1b73ebabf2bc 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs @@ -10,6 +10,7 @@ public interface IGetOrganizationUsersManagementStatusQuery /// /// A managed user is a user whose email domain matches one of the Organization's verified domains. /// The organization must be enabled and be on an Enterprise plan. + /// The user must be a member of the organization with a confirmed or revoked status. /// /// /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization. diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 5b274d3f88af..b39d7dd51f56 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -19,7 +19,12 @@ public interface IOrganizationRepository : IRepository Task> GetOwnerEmailAddressesById(Guid organizationId); /// - /// Gets the organizations that have a verified domain matching the user's email domain. + /// Retrieves organizations where the user's email domain matches a verified organization domain. /// + /// + /// Only returns organizations where: + /// 1. The domain in the user's email matches a verified organization domain + /// 2. The user is an existing organization member with either 'Confirmed' or 'Revoked' status + /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index a3a68b5de264..7b68e8a27359 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -57,5 +57,8 @@ UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, /// /// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains. /// + /// + /// Only returns users with a confirmed or revoked status. + /// Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 65bec5ea9f78..ca4eef07fc8c 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -90,8 +90,10 @@ Task UpdatePasswordHash(User user, string newPassword, /// Indicates if the user is managed by any organization. /// /// - /// A user is considered managed by an organization if their email domain matches one of the verified domains of that organization, and the user is a member of it. - /// The organization must be enabled and able to have verified domains. + /// A user is considered "managed" when they meet all these criteria: + /// 1. Their email domain matches a verified domain of an organization + /// 2. They are a member of that organization (with confirmed or revoked status) + /// 3. The organization is enabled and has domain verification capabilities /// /// /// False if the Account Deprovisioning feature flag is disabled. diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index b3ee25488961..a9b75da8ddff 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -290,8 +290,9 @@ join ou in dbContext.OrganizationUsers on u.Id equals ou.UserId join o in dbContext.Organizations on ou.OrganizationId equals o.Id join od in dbContext.OrganizationDomains on ou.OrganizationId equals od.OrganizationId where u.Id == userId - && od.VerifiedDate != null - && u.Email.ToLower().EndsWith("@" + od.DomainName.ToLower()) + && (ou.Status == OrganizationUserStatusType.Confirmed || ou.Status == OrganizationUserStatusType.Revoked) + && od.VerifiedDate != null + && u.Email.ToLower().EndsWith("@" + od.DomainName.ToLower()) select o; return await query.ToArrayAsync(); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs index d328691df0d2..d102f0ff5f01 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.Enums; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -16,8 +17,9 @@ public IQueryable 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 - && dbContext.OrganizationDomains - .Any(od => od.OrganizationId == _organizationId && + && (ou.Status == OrganizationUserStatusType.Confirmed || ou.Status == OrganizationUserStatusType.Revoked) + && dbContext.OrganizationDomains + .Any(od => od.OrganizationId == _organizationId && od.VerifiedDate != null && u.Email.ToLower().EndsWith("@" + od.DomainName.ToLower())) select ou; diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql index bb10a1a4810b..7e00ffcb8791 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql @@ -8,6 +8,10 @@ BEGIN FROM [dbo].[OrganizationUserView] OU INNER JOIN [dbo].[UserView] U ON OU.[UserId] = U.[Id] WHERE OU.[OrganizationId] = @OrganizationId + AND ( + OU.[Status] = 2 -- Confirmed + OR OU.[Status] = -1 -- Revoked + ) AND EXISTS ( SELECT 1 FROM [dbo].[OrganizationDomainView] OD diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql index 39cf5d384c9c..a6caf2747404 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql @@ -10,6 +10,10 @@ BEGIN INNER JOIN [dbo].[OrganizationView] O ON OU.[OrganizationId] = O.[Id] INNER JOIN [dbo].[OrganizationDomainView] OD ON OU.[OrganizationId] = OD.[OrganizationId] WHERE U.[Id] = @UserId + AND ( + OU.[Status] = 2 -- Confirmed + OR OU.[Status] = -1 -- Revoked + ) AND OD.[VerifiedDate] IS NOT NULL AND U.[Email] LIKE '%@' + OD.[DomainName]; END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs index f6dc4a989d8e..dff25e5391f2 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -253,4 +253,80 @@ public async Task GetByVerifiedUserEmailDomainAsync_WithNonExistentUser_ReturnsE Assert.Empty(result); } + + [DatabaseTheory, DatabaseData] + public async Task GetByClaimedUserDomainAsync_WithNonConfirmedOrRevokedUsers_ReturnsEmpty( + 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 organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl + Plan = "Test", // TODO: EF does not enforce this being NOT NULl + PrivateKey = "privatekey", + }); + + 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.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Invited, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Accepted, + ResetPasswordKey = "resetpasswordkey1", + }); + + var user1Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user1.Id); + var user2Response = await organizationRepository.GetByVerifiedUserEmailDomainAsync(user2.Id); + + Assert.Empty(user1Response); + Assert.Empty(user2Response); + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index dba511074eb7..0de0ecfe4b4d 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -355,4 +355,79 @@ await organizationUserRepository.CreateAsync(new OrganizationUser Assert.Single(responseModel); Assert.Equal(orgUser1.Id, responseModel.Single().Id); } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithNonConfirmedOrRevokedUsers_ReturnsEmpty( + 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 organization = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULl + Plan = "Test", // TODO: EF does not enforce this being NOT NULl + PrivateKey = "privatekey", + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = domainName, + Txt = "btw+12345", + }; + organizationDomain.SetVerifiedDate(); + organizationDomain.SetNextRunDate(12); + organizationDomain.SetJobRunCount(); + await organizationDomainRepository.CreateAsync(organizationDomain); + + var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Invited, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Accepted, + ResetPasswordKey = "resetpasswordkey1", + }); + + var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); + + Assert.NotNull(responseModel); + Assert.Empty(responseModel); + } } diff --git a/util/Migrator/DbScripts/2024-11-05_00_UsersManagedByOrgConfirmedRevoked.sql b/util/Migrator/DbScripts/2024-11-05_00_UsersManagedByOrgConfirmedRevoked.sql new file mode 100644 index 000000000000..cff5c1ce24dd --- /dev/null +++ b/util/Migrator/DbScripts/2024-11-05_00_UsersManagedByOrgConfirmedRevoked.sql @@ -0,0 +1,44 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + SELECT OU.* + FROM [dbo].[OrganizationUserView] OU + INNER JOIN [dbo].[UserView] U ON OU.[UserId] = U.[Id] + WHERE OU.[OrganizationId] = @OrganizationId + AND ( + OU.[Status] = 2 -- Confirmed + OR OU.[Status] = -1 -- Revoked + ) + AND EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomainView] OD + WHERE OD.[OrganizationId] = @OrganizationId + AND OD.[VerifiedDate] IS NOT NULL + AND U.[Email] LIKE '%@' + OD.[DomainName] + ); +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadByClaimedUserEmailDomain] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + SELECT O.* + FROM [dbo].[UserView] U + INNER JOIN [dbo].[OrganizationUserView] OU ON U.[Id] = OU.[UserId] + INNER JOIN [dbo].[OrganizationView] O ON OU.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[OrganizationDomainView] OD ON OU.[OrganizationId] = OD.[OrganizationId] + WHERE U.[Id] = @UserId + AND ( + OU.[Status] = 2 -- Confirmed + OR OU.[Status] = -1 -- Revoked + ) + AND OD.[VerifiedDate] IS NOT NULL + AND U.[Email] LIKE '%@' + OD.[DomainName]; +END +GO