diff --git a/src/Admin/Jobs/DeleteUnverifiedOrganizationDomainsJob.cs b/src/Admin/Jobs/DeleteUnverifiedOrganizationDomainsJob.cs index 0d2f2d860..d182a8a3f 100644 --- a/src/Admin/Jobs/DeleteUnverifiedOrganizationDomainsJob.cs +++ b/src/Admin/Jobs/DeleteUnverifiedOrganizationDomainsJob.cs @@ -1,6 +1,6 @@ using Bit.Core; +using Bit.Core.AdminConsole.Services; using Bit.Core.Jobs; -using Bit.Core.Services; using Quartz; namespace Bit.Admin.Jobs; diff --git a/src/Api/Jobs/ValidateOrganizationDomainJob.cs b/src/Api/Jobs/ValidateOrganizationDomainJob.cs index 3d0f2d76c..1dce5936c 100644 --- a/src/Api/Jobs/ValidateOrganizationDomainJob.cs +++ b/src/Api/Jobs/ValidateOrganizationDomainJob.cs @@ -1,6 +1,6 @@ using Bit.Core; +using Bit.Core.AdminConsole.Services; using Bit.Core.Jobs; -using Bit.Core.Services; using Quartz; namespace Bit.Api.Jobs; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs new file mode 100644 index 000000000..4ff6b8744 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQuery.cs @@ -0,0 +1,41 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class GetOrganizationUsersManagementStatusQuery : IGetOrganizationUsersManagementStatusQuery +{ + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public GetOrganizationUsersManagementStatusQuery( + IApplicationCacheService applicationCacheService, + IOrganizationUserRepository organizationUserRepository) + { + _applicationCacheService = applicationCacheService; + _organizationUserRepository = organizationUserRepository; + } + + public async Task> GetUsersOrganizationManagementStatusAsync(Guid organizationId, IEnumerable organizationUserIds) + { + if (organizationUserIds.Any()) + { + // Users can only be managed by an Organization that is enabled and can have organization domains + var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + + // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). + // Verified domains were tied to SSO, so we currently check the "UseSso" organization ability. + if (organizationAbility is { Enabled: true, UseSso: true }) + { + // Get all organization users with claimed domains by the organization + var organizationUsersWithClaimedDomain = await _organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organizationId); + + // Create a dictionary with the OrganizationUserId and a boolean indicating if the user is managed by the organization + return organizationUserIds.ToDictionary(ouId => ouId, ouId => organizationUsersWithClaimedDomain.Any(ou => ou.Id == ouId)); + } + } + + return organizationUserIds.ToDictionary(ouId => ouId, _ => false); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs new file mode 100644 index 000000000..694b44dd7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IGetOrganizationUsersManagementStatusQuery.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IGetOrganizationUsersManagementStatusQuery +{ + /// + /// Checks whether each user in the provided list of organization user IDs is managed by the specified organization. + /// + /// The unique identifier of the organization to check against. + /// A list of OrganizationUserIds to be checked. + /// + /// 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. + /// + /// + /// A dictionary containing the OrganizationUserId and a boolean indicating if the user is managed by the organization. + /// + Task> GetUsersOrganizationManagementStatusAsync(Guid organizationId, + IEnumerable organizationUserIds); +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index d6ab24072..9c14c4fbd 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -17,4 +17,9 @@ public interface IOrganizationRepository : IRepository Task GetSelfHostedOrganizationDetailsById(Guid id); Task> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take); Task> GetOwnerEmailAddressesById(Guid organizationId); + + /// + /// Gets the organization that has a claimed domain matching the user's email domain. + /// + Task GetByClaimedUserDomainAsync(Guid userId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 7d115c6b8..54040e6dc 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -55,4 +55,8 @@ public interface IOrganizationUserRepository : IRepository resetPasswordKeys); + /// + /// Returns a list of OrganizationUsers with email domains that match one of the Organization's claimed domains. + /// + Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); } diff --git a/src/Core/AdminConsole/Services/IOrganizationDomainService.cs b/src/Core/AdminConsole/Services/IOrganizationDomainService.cs new file mode 100644 index 000000000..8ed543f0e --- /dev/null +++ b/src/Core/AdminConsole/Services/IOrganizationDomainService.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Services; + +public interface IOrganizationDomainService +{ + Task ValidateOrganizationsDomainAsync(); + Task OrganizationDomainMaintenanceAsync(); + /// + /// Indicates if the organization has any verified domains. + /// + Task HasVerifiedDomainsAsync(Guid orgId); +} diff --git a/src/Core/Services/Implementations/OrganizationDomainService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs similarity index 91% rename from src/Core/Services/Implementations/OrganizationDomainService.cs rename to src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs index ba342ce03..b526f27d9 100644 --- a/src/Core/Services/Implementations/OrganizationDomainService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationDomainService.cs @@ -1,9 +1,10 @@ using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.AdminConsole.Services.Implementations; public class OrganizationDomainService : IOrganizationDomainService { @@ -53,7 +54,7 @@ public class OrganizationDomainService : IOrganizationDomainService { _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain"); - //update entry on OrganizationDomain table + // Update entry on OrganizationDomain table domain.SetLastCheckedDate(); domain.SetVerifiedDate(); domain.SetJobRunCount(); @@ -64,7 +65,7 @@ public class OrganizationDomainService : IOrganizationDomainService } else { - //update entry on OrganizationDomain table + // Update entry on OrganizationDomain table domain.SetLastCheckedDate(); domain.SetJobRunCount(); domain.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); @@ -78,7 +79,7 @@ public class OrganizationDomainService : IOrganizationDomainService } catch (Exception ex) { - //update entry on OrganizationDomain table + // Update entry on OrganizationDomain table domain.SetLastCheckedDate(); domain.SetJobRunCount(); domain.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); @@ -117,7 +118,7 @@ public class OrganizationDomainService : IOrganizationDomainService _logger.LogInformation(Constants.BypassFiltersEventId, "Expired domain: {domainName}", domain.DomainName); } - //delete domains that have not been verified within 7 days + // Delete domains that have not been verified within 7 days var status = await _domainRepository.DeleteExpiredAsync(_globalSettings.DomainVerification.ExpirationPeriod); _logger.LogInformation(Constants.BypassFiltersEventId, "Delete status {status}", status); } @@ -127,6 +128,12 @@ public class OrganizationDomainService : IOrganizationDomainService } } + public async Task HasVerifiedDomainsAsync(Guid orgId) + { + var orgDomains = await _domainRepository.GetDomainsByOrganizationIdAsync(orgId); + return orgDomains.Any(od => od.VerifiedDate != null); + } + private async Task> GetAdminEmailsAsync(Guid organizationId) { var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index a18d9f1f5..0d623d5b3 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -139,6 +139,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/src/Core/Services/IOrganizationDomainService.cs b/src/Core/Services/IOrganizationDomainService.cs deleted file mode 100644 index 87e7668ea..000000000 --- a/src/Core/Services/IOrganizationDomainService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Services; - -public interface IOrganizationDomainService -{ - Task ValidateOrganizationsDomainAsync(); - Task OrganizationDomainMaintenanceAsync(); -} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 0888fb7cf..f3ada234a 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -86,4 +86,13 @@ public interface IUserService /// We force these users to the web to migrate their encryption scheme. /// Task IsLegacyUser(string userId); + + /// + /// Indicates if the user is managed by any organization. + /// + /// + /// 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. + /// + Task IsManagedByAnyOrganizationAsync(Guid userId); } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 51ce0af21..46f48ef26 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1244,6 +1244,16 @@ public class UserService : UserManager, IUserService, IDisposable return IsLegacyUser(user); } + public async Task IsManagedByAnyOrganizationAsync(Guid userId) + { + // Users can only be managed by an Organization that is enabled and can have organization domains + var organization = await _organizationRepository.GetByClaimedUserDomainAsync(userId); + + // TODO: Replace "UseSso" with a new organization ability like "UseOrganizationDomains" (PM-11622). + // Verified domains were tied to SSO, so we currently check the "UseSso" organization ability. + return organization is { Enabled: true, UseSso: true }; + } + /// public static bool IsLegacyUser(User user) { diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 704bb85f9..bdc2fb4ca 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -167,4 +167,17 @@ public class OrganizationRepository : Repository, IOrganizat new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); } + + public async Task GetByClaimedUserDomainAsync(Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.QueryAsync( + "[dbo].[Organization_ReadByClaimedUserEmailDomain]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return result.SingleOrDefault(); + } + } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 20f5ae48b..6da2f581f 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -545,4 +545,17 @@ public class OrganizationUserRepository : Repository, IO transaction: transaction, commandType: CommandType.StoredProcedure); } + + public async Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 601ca1275..f9f2fecd3 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -271,6 +271,25 @@ public class OrganizationRepository : Repository GetByClaimedUserDomainAsync(Guid userId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var query = from u in dbContext.Users + 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()) + select o; + + return await query.FirstOrDefaultAsync(); + } + } + public Task EnableCollectionEnhancements(Guid organizationId) { throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework."); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 0252e78ae..089a0a5c5 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -711,4 +711,14 @@ public class OrganizationUserRepository : Repository> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationUserReadByClaimedOrganizationDomainsQuery(organizationId); + var data = await query.Run(dbContext).ToListAsync(); + return data; + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs new file mode 100644 index 000000000..d328691df --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadByClaimedOrganizationDomainsQuery.cs @@ -0,0 +1,27 @@ +using Bit.Core.Entities; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class OrganizationUserReadByClaimedOrganizationDomainsQuery : IQuery +{ + private readonly Guid _organizationId; + + public OrganizationUserReadByClaimedOrganizationDomainsQuery(Guid organizationId) + { + _organizationId = organizationId; + } + + 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 && + od.VerifiedDate != null && + u.Email.ToLower().EndsWith("@" + od.DomainName.ToLower())) + select ou; + + return query; + } +} diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql new file mode 100644 index 000000000..bb10a1a48 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains.sql @@ -0,0 +1,18 @@ +CREATE 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 EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomainView] OD + WHERE OD.[OrganizationId] = @OrganizationId + AND OD.[VerifiedDate] IS NOT NULL + AND U.[Email] LIKE '%@' + OD.[DomainName] + ); +END diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql new file mode 100644 index 000000000..39cf5d384 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql @@ -0,0 +1,15 @@ +CREATE 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 OD.[VerifiedDate] IS NOT NULL + AND U.[Email] LIKE '%@' + OD.[DomainName]; +END diff --git a/src/Sql/dbo/Tables/OrganizationDomain.sql b/src/Sql/dbo/Tables/OrganizationDomain.sql index d7585167a..09e4997d7 100644 --- a/src/Sql/dbo/Tables/OrganizationDomain.sql +++ b/src/Sql/dbo/Tables/OrganizationDomain.sql @@ -12,4 +12,13 @@ CREATE TABLE [dbo].[OrganizationDomain] ( CONSTRAINT [FK_OrganzationDomain_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ); -GO \ No newline at end of file +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationIdVerifiedDate] + ON [dbo].[OrganizationDomain] ([OrganizationId],[VerifiedDate]); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_VerifiedDate] + ON [dbo].[OrganizationDomain] ([VerifiedDate]) + INCLUDE ([OrganizationId],[DomainName]); +GO diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs new file mode 100644 index 000000000..dda9867fd --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/GetOrganizationUsersManagementStatusQueryTests.cs @@ -0,0 +1,101 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class GetOrganizationUsersManagementStatusQueryTests +{ + [Theory, BitAutoData] + public async Task GetUsersOrganizationManagementStatusAsync_WithNoUsers_ReturnsEmpty( + Organization organization, + SutProvider sutProvider) + { + var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, new List()); + + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task GetUsersOrganizationManagementStatusAsync_WithUseSsoEnabled_Success( + Organization organization, + ICollection usersWithClaimedDomain, + SutProvider sutProvider) + { + organization.Enabled = true; + organization.UseSso = true; + + var userIdWithoutClaimedDomain = Guid.NewGuid(); + var userIdsToCheck = usersWithClaimedDomain.Select(u => u.Id).Concat(new List { userIdWithoutClaimedDomain }).ToList(); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility(organization)); + + sutProvider.GetDependency() + .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) + .Returns(usersWithClaimedDomain); + + var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + + Assert.All(usersWithClaimedDomain, ou => Assert.True(result[ou.Id])); + Assert.False(result[userIdWithoutClaimedDomain]); + } + + [Theory, BitAutoData] + public async Task GetUsersOrganizationManagementStatusAsync_WithUseSsoDisabled_ReturnsAllFalse( + Organization organization, + ICollection usersWithClaimedDomain, + SutProvider sutProvider) + { + organization.Enabled = true; + organization.UseSso = false; + + var userIdWithoutClaimedDomain = Guid.NewGuid(); + var userIdsToCheck = usersWithClaimedDomain.Select(u => u.Id).Concat(new List { userIdWithoutClaimedDomain }).ToList(); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility(organization)); + + sutProvider.GetDependency() + .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) + .Returns(usersWithClaimedDomain); + + var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + + Assert.All(result, r => Assert.False(r.Value)); + } + + [Theory, BitAutoData] + public async Task GetUsersOrganizationManagementStatusAsync_WithDisabledOrganization_ReturnsAllFalse( + Organization organization, + ICollection usersWithClaimedDomain, + SutProvider sutProvider) + { + organization.Enabled = false; + + var userIdWithoutClaimedDomain = Guid.NewGuid(); + var userIdsToCheck = usersWithClaimedDomain.Select(u => u.Id).Concat(new List { userIdWithoutClaimedDomain }).ToList(); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility(organization)); + + sutProvider.GetDependency() + .GetManyByOrganizationWithClaimedDomainsAsync(organization.Id) + .Returns(usersWithClaimedDomain); + + var result = await sutProvider.Sut.GetUsersOrganizationManagementStatusAsync(organization.Id, userIdsToCheck); + + Assert.All(result, r => Assert.False(r.Value)); + } +} diff --git a/test/Core.Test/Services/OrganizationDomainServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs similarity index 60% rename from test/Core.Test/Services/OrganizationDomainServiceTests.cs rename to test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs index b6f299b3a..ddd9accd0 100644 --- a/test/Core.Test/Services/OrganizationDomainServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationDomainServiceTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.AdminConsole.Services.Implementations; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; @@ -7,7 +8,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Services; +namespace Bit.Core.Test.AdminConsole.Services; [SutProviderCustomize] public class OrganizationDomainServiceTests @@ -80,4 +81,48 @@ public class OrganizationDomainServiceTests await sutProvider.GetDependency().ReceivedWithAnyArgs(1) .DeleteExpiredAsync(7); } + + [Theory, BitAutoData] + public async Task HasVerifiedDomainsAsync_WithVerifiedDomain_ReturnsTrue( + OrganizationDomain organizationDomain, + SutProvider sutProvider) + { + organizationDomain.SetVerifiedDate(); // Set the verified date to make it verified + + sutProvider.GetDependency() + .GetDomainsByOrganizationIdAsync(organizationDomain.OrganizationId) + .Returns(new List { organizationDomain }); + + var result = await sutProvider.Sut.HasVerifiedDomainsAsync(organizationDomain.OrganizationId); + + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task HasVerifiedDomainsAsync_WithoutVerifiedDomain_ReturnsFalse( + OrganizationDomain organizationDomain, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetDomainsByOrganizationIdAsync(organizationDomain.OrganizationId) + .Returns(new List { organizationDomain }); + + var result = await sutProvider.Sut.HasVerifiedDomainsAsync(organizationDomain.OrganizationId); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task HasVerifiedDomainsAsync_WithoutOrganizationDomains_ReturnsFalse( + Guid organizationId, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetDomainsByOrganizationIdAsync(organizationId) + .Returns(new List()); + + var result = await sutProvider.Sut.HasVerifiedDomainsAsync(organizationId); + + Assert.False(result); + } } diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 19ef6991d..1c727adee 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -276,6 +276,51 @@ public class UserServiceTests .VerifyHashedPassword(user, "hashed_test_password", secret); } + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithManagingEnabledOrganization_ReturnsTrue( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = true; + organization.UseSso = true; + + sutProvider.GetDependency() + .GetByClaimedUserDomainAsync(userId) + .Returns(organization); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithManagingDisabledOrganization_ReturnsFalse( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = false; + organization.UseSso = true; + + sutProvider.GetDependency() + .GetByClaimedUserDomainAsync(userId) + .Returns(organization); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task IsManagedByAnyOrganizationAsync_WithOrganizationUseSsoFalse_ReturnsFalse( + SutProvider sutProvider, Guid userId, Organization organization) + { + organization.Enabled = true; + organization.UseSso = false; + + sutProvider.GetDependency() + .GetByClaimedUserDomainAsync(userId) + .Returns(organization); + + var result = await sutProvider.Sut.IsManagedByAnyOrganizationAsync(userId); + Assert.False(result); + } + private static void SetupUserAndDevice(User user, bool shouldHavePassword) { diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs new file mode 100644 index 000000000..eac71e9c2 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs @@ -0,0 +1,109 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Repositories; + +public class OrganizationRepositoryTests +{ + [DatabaseTheory, 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.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.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + var user1Response = await organizationRepository.GetByClaimedUserDomainAsync(user1.Id); + var user2Response = await organizationRepository.GetByClaimedUserDomainAsync(user2.Id); + var user3Response = await organizationRepository.GetByClaimedUserDomainAsync(user3.Id); + + Assert.NotNull(user1Response); + Assert.Equal(organization.Id, user1Response.Id); + Assert.Null(user2Response); + Assert.Null(user3Response); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 46d3e60ee..3b102c788 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -256,4 +256,100 @@ public class OrganizationUserRepositoryTests Assert.Equal(organization.LimitCollectionCreationDeletion, result.LimitCollectionCreationDeletion); Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems); } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByOrganizationWithClaimedDomainsAsync_WithVerifiedDomain_WithOneMatchingEmailDomain_ReturnsSingle( + 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.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.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user2.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Confirmed, + ResetPasswordKey = "resetpasswordkey1", + }); + + var responseModel = await organizationUserRepository.GetManyByOrganizationWithClaimedDomainsAsync(organization.Id); + + Assert.NotNull(responseModel); + Assert.Single(responseModel); + Assert.Equal(orgUser1.Id, responseModel.Single().Id); + } } diff --git a/util/Migrator/DbScripts/2024-09-10_00_UsersManagedByOrg.sql b/util/Migrator/DbScripts/2024-09-10_00_UsersManagedByOrg.sql new file mode 100644 index 000000000..5ff1d7739 --- /dev/null +++ b/util/Migrator/DbScripts/2024-09-10_00_UsersManagedByOrg.sql @@ -0,0 +1,55 @@ +IF NOT EXISTS(SELECT name +FROM sys.indexes +WHERE name = 'IX_OrganizationDomain_OrganizationIdVerifiedDate') +BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationIdVerifiedDate] + ON [dbo].[OrganizationDomain] ([OrganizationId],[VerifiedDate]); +END +GO + +IF NOT EXISTS(SELECT name +FROM sys.indexes +WHERE name = 'IX_OrganizationDomain_VerifiedDate') +BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_VerifiedDate] + ON [dbo].[OrganizationDomain] ([VerifiedDate]) + INCLUDE ([OrganizationId],[DomainName]); +END +GO + +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 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 OD.[VerifiedDate] IS NOT NULL + AND U.[Email] LIKE '%@' + OD.[DomainName]; +END +GO