From b6bd041b3006baa8000ad944f26f106f6c3e4330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:54:43 +0100 Subject: [PATCH] [EC-432] Add existing Organizations to Provider (#2683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [EC-432] Added ProviderOrganizationUnassignedOrganizationDetails_Search stored procedure * [EC-432] Added IProviderOrganizationRepository.SearchAsync * [EC-432] Created controller ProviderOrganizationsController to assign Organizations to a Provider * [EC-432] Filter existing organizations by plans Enterprise or Team * [EC-432] Existing Organization name links to edit page * [EC-432] EF filtering out existing organizations by plan type enterprise or teams * [EC-432] Creating multiple ProviderOrganization records * [EC-432] Added ProviderOrganizationUnassignedOrganizationDetails_Search stored procedure * [EC-432] Added IProviderOrganizationRepository.SearchAsync * [EC-432] Created controller ProviderOrganizationsController to assign Organizations to a Provider * [EC-432] Filter existing organizations by plans Enterprise or Team * [EC-432] Existing Organization name links to edit page * [EC-432] EF filtering out existing organizations by plan type enterprise or teams * [EC-432] Creating multiple ProviderOrganization records * [EC-432] Renamed migration script and added missing sproc * [EC-432] Saving multiple events for the created ProviderOrganizations * [EC-432] Included unit testing for ProviderService.AddOrganizations and EventService.LogProviderOrganizationEventsAsync * [EC-432] Removed async from NoopEventService.LogProviderOrganizationEventsAsync * [EC-432] Remove unused dependency setup in ProviderServiceTests.AddOrganizations_Success * [EC-432] Renamed AddOrganizations to AddOrganizationsToReseller and removed addingUserId and key arguments * [EC-432] Added DisplayName attributes to ProviderOrganizationViewModel and used them in the view * [EC-432] Reverted changes to input fields * [EC-432] Moved unassigned organizations search to Organizations repo * [EC-432] Moved AddExistingOrganization action to ProvidersController * [EC-432] dotnet format * [EC-432] Fixed unit test issues * [EC-432] Removed unnecessary Html.DisplayNameFor for labels * [EC-432] Renamed OrganizationSearchViewModel to OrganizationUnassignedToProviderSearchViewModel * [EC-432] Modified IEventService.LogProviderOrganizationEventsAsync to receive an IEnumerable as parameter * [EC-432] Updated IProviderOrganizationRepository and replaced CreateWithManyOrganizations method with CreateManyAsync * [EC-432] Deleted ProviderOrganization_CreateWithManyOrganizations * [AC-432] Simplified Organization_UnassignedToProviderSearch query * [AC-432] Removed unnecessary setup * [EC-432] Checking if stored procedure exists before creating * [EC-432] Renamed migration file to recent date --- .../Services/ProviderService.cs | 14 +++ .../Services/ProviderServiceTests.cs | 43 ++++++++ src/Admin/Controllers/ProvidersController.cs | 47 +++++++++ .../Models/OrganizationSelectableViewModel.cs | 8 ++ ...tionUnassignedToProviderSearchViewModel.cs | 12 +++ .../Providers/AddExistingOrganization.cshtml | 97 +++++++++++++++++++ .../Repositories/IOrganizationRepository.cs | 1 + .../IProviderOrganizationRepository.cs | 1 + src/Core/Services/IEventService.cs | 1 + src/Core/Services/IProviderService.cs | 1 + .../Services/Implementations/EventService.cs | 36 ++++--- .../NoopImplementations/NoopEventService.cs | 7 +- .../NoopProviderService.cs | 2 + .../Repositories/OrganizationRepository.cs | 14 +++ .../ProviderOrganizationRepository.cs | 94 ++++++++++++++++++ .../Repositories/OrganizationRepository.cs | 26 +++++ .../ProviderOrganizationRepository.cs | 24 +++++ src/Sql/Sql.sqlproj | 1 + ...rganization_UnassignedToProviderSearch.sql | 45 +++++++++ test/Core.Test/Services/EventServiceTests.cs | 36 +++++++ .../OrganizationRepositoryTests.cs | 42 +++++++- ...22_00_ProviderAddExistingOrganizations.sql | 53 ++++++++++ 22 files changed, 591 insertions(+), 14 deletions(-) create mode 100644 src/Admin/Models/OrganizationSelectableViewModel.cs create mode 100644 src/Admin/Models/OrganizationUnassignedToProviderSearchViewModel.cs create mode 100644 src/Admin/Views/Providers/AddExistingOrganization.cshtml create mode 100644 src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql create mode 100644 util/Migrator/DbScripts/2023-03-22_00_ProviderAddExistingOrganizations.sql diff --git a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs index 3a6e47f240..ef9c7c7840 100644 --- a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs @@ -365,6 +365,20 @@ public class ProviderService : IProviderService await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added); } + public async Task AddOrganizationsToReseller(Guid providerId, IEnumerable organizationIds) + { + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider.Type != ProviderType.Reseller) + { + throw new BadRequestException("Organization must be of type Reseller in order to assign Organizations to it."); + } + + var providerOrganizationsToInsert = organizationIds.Select(orgId => new ProviderOrganization { ProviderId = providerId, OrganizationId = orgId }); + var insertedProviderOrganizations = await _providerOrganizationRepository.CreateManyAsync(providerOrganizationsToInsert); + + await _eventService.LogProviderOrganizationEventsAsync(insertedProviderOrganizations.Select(ipo => (ipo, EventType.ProviderOrganization_Added, (DateTime?)null))); + } + public async Task CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user) { diff --git a/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs index 12c7dcf222..218cb5c157 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs @@ -450,6 +450,49 @@ public class ProviderServiceTests EventType.ProviderOrganization_Added); } + [Theory, BitAutoData] + public async Task AddOrganizationsToReseller_WithResellerProvider_Success(Provider provider, ICollection organizations, SutProvider sutProvider) + { + provider.Type = ProviderType.Reseller; + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + + var providerOrganizationRepository = sutProvider.GetDependency(); + foreach (var organization in organizations) + { + organization.PlanType = PlanType.EnterpriseAnnually; + } + + var organizationIds = organizations.Select(o => o.Id).ToArray(); + + await sutProvider.Sut.AddOrganizationsToReseller(provider.Id, organizationIds); + + await providerOrganizationRepository.Received(1).CreateManyAsync(Arg.Is>(i => i.All(po => po.ProviderId == provider.Id && organizations.Any(o => o.Id == po.OrganizationId)))); + await sutProvider.GetDependency().Received(1).LogProviderOrganizationEventsAsync( + Arg.Is>(events => events.All(e => + e.Item1.ProviderId == provider.Id && organizationIds.Contains(e.Item1.OrganizationId) && e.Item2 == EventType.ProviderOrganization_Added))); + } + + [Theory, BitAutoData] + public async Task AddOrganizationsToReseller_WithMspProvider_Throws(Provider provider, ICollection organizations, SutProvider sutProvider) + { + provider.Type = ProviderType.Msp; + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + + var providerOrganizationRepository = sutProvider.GetDependency(); + foreach (var organization in organizations) + { + organization.PlanType = PlanType.EnterpriseAnnually; + } + + var organizationIds = organizations.Select(o => o.Id).ToArray(); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.AddOrganizationsToReseller(provider.Id, organizationIds)); + Assert.Contains("Organization must be of type Reseller in order to assign Organizations to it.", exception.Message); + + await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateManyAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default); + } + [Theory, BitAutoData] public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail, User user, SutProvider sutProvider) diff --git a/src/Admin/Controllers/ProvidersController.cs b/src/Admin/Controllers/ProvidersController.cs index 8d89115f54..def79dbde7 100644 --- a/src/Admin/Controllers/ProvidersController.cs +++ b/src/Admin/Controllers/ProvidersController.cs @@ -15,6 +15,7 @@ namespace Bit.Admin.Controllers; [SelfHosted(NotSelfHostedOnly = true)] public class ProvidersController : Controller { + private readonly IOrganizationRepository _organizationRepository; private readonly IProviderRepository _providerRepository; private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository; @@ -24,6 +25,7 @@ public class ProvidersController : Controller private readonly ICreateProviderCommand _createProviderCommand; public ProvidersController( + IOrganizationRepository organizationRepository, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, @@ -32,6 +34,7 @@ public class ProvidersController : Controller IApplicationCacheService applicationCacheService, ICreateProviderCommand createProviderCommand) { + _organizationRepository = organizationRepository; _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; _providerOrganizationRepository = providerOrganizationRepository; @@ -148,4 +151,48 @@ public class ProvidersController : Controller TempData["InviteResentTo"] = ownerId; return RedirectToAction("Edit", new { id = providerId }); } + + [HttpGet] + public async Task AddExistingOrganization(Guid id, string name = null, string ownerEmail = null, int page = 1, int count = 25) + { + if (page < 1) + { + page = 1; + } + + if (count < 1) + { + count = 1; + } + + var skip = (page - 1) * count; + var unassignedOrganizations = await _organizationRepository.SearchUnassignedToProviderAsync(name, ownerEmail, skip, count); + var viewModel = new OrganizationUnassignedToProviderSearchViewModel + { + OrganizationName = string.IsNullOrWhiteSpace(name) ? null : name, + OrganizationOwnerEmail = string.IsNullOrWhiteSpace(ownerEmail) ? null : ownerEmail, + Page = page, + Count = count, + Items = unassignedOrganizations.Select(uo => new OrganizationSelectableViewModel + { + Id = uo.Id, + Name = uo.Name, + PlanType = uo.PlanType + }).ToList() + }; + + return View(viewModel); + } + + [HttpPost] + public async Task AddExistingOrganization(Guid id, OrganizationUnassignedToProviderSearchViewModel model) + { + var organizationIds = model.Items.Where(o => o.Selected).Select(o => o.Id).ToArray(); + if (organizationIds.Any()) + { + await _providerService.AddOrganizationsToReseller(id, organizationIds); + } + + return RedirectToAction("Edit", "Providers", new { id = id }); + } } diff --git a/src/Admin/Models/OrganizationSelectableViewModel.cs b/src/Admin/Models/OrganizationSelectableViewModel.cs new file mode 100644 index 0000000000..0daa5fda88 --- /dev/null +++ b/src/Admin/Models/OrganizationSelectableViewModel.cs @@ -0,0 +1,8 @@ +using Bit.Core.Entities; + +namespace Bit.Admin.Models; + +public class OrganizationSelectableViewModel : Organization +{ + public bool Selected { get; set; } +} diff --git a/src/Admin/Models/OrganizationUnassignedToProviderSearchViewModel.cs b/src/Admin/Models/OrganizationUnassignedToProviderSearchViewModel.cs new file mode 100644 index 0000000000..73aee284c8 --- /dev/null +++ b/src/Admin/Models/OrganizationUnassignedToProviderSearchViewModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Admin.Models; + +public class OrganizationUnassignedToProviderSearchViewModel : PagedModel +{ + [Display(Name = "Organization Name")] + public string OrganizationName { get; set; } + + [Display(Name = "Owner Email")] + public string OrganizationOwnerEmail { get; set; } +} diff --git a/src/Admin/Views/Providers/AddExistingOrganization.cshtml b/src/Admin/Views/Providers/AddExistingOrganization.cshtml new file mode 100644 index 0000000000..86ad5f7492 --- /dev/null +++ b/src/Admin/Views/Providers/AddExistingOrganization.cshtml @@ -0,0 +1,97 @@ +@using Bit.SharedWeb.Utilities +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model OrganizationUnassignedToProviderSearchViewModel + +@{ + ViewData["Title"] = "Add Existing Organization"; + var providerId = ViewContext.RouteData.Values["id"]; +} + +

Add Existing Organization

+
+
+
+ + + + + +
+
+
+ +
+
+ + + + + + + + + + @if (!Model.Items.Any()) + { + + + + } + else + { + @for (var i = 0; i < Model.Items.Count; i++) + { + + + + + + } + } + +
AllNamePlan
No results to list.
+ @Html.HiddenFor(m => Model.Items[i].Id, new { @readonly = "readonly", autocomplete = "off" }) + @Html.CheckBoxFor(m => Model.Items[i].Selected) + @Html.ActionLink(Model.Items[i].Name, "Edit", "Organizations", new { id = Model.Items[i].Id }, new { target = "_blank" })@(Model.Items[i].PlanType.GetDisplayAttribute()?.Name ?? Model.Items[i].PlanType.ToString())
+
+
+ +
+
+ +
+
+ +
+
diff --git a/src/Core/Repositories/IOrganizationRepository.cs b/src/Core/Repositories/IOrganizationRepository.cs index e8bdc869ac..14126adb0a 100644 --- a/src/Core/Repositories/IOrganizationRepository.cs +++ b/src/Core/Repositories/IOrganizationRepository.cs @@ -13,4 +13,5 @@ public interface IOrganizationRepository : IRepository Task> GetManyAbilitiesAsync(); Task GetByLicenseKeyAsync(string licenseKey); Task GetSelfHostedOrganizationDetailsById(Guid id); + Task> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take); } diff --git a/src/Core/Repositories/IProviderOrganizationRepository.cs b/src/Core/Repositories/IProviderOrganizationRepository.cs index b546d8d2ef..cc2d3d54ff 100644 --- a/src/Core/Repositories/IProviderOrganizationRepository.cs +++ b/src/Core/Repositories/IProviderOrganizationRepository.cs @@ -5,6 +5,7 @@ namespace Bit.Core.Repositories; public interface IProviderOrganizationRepository : IRepository { + Task> CreateManyAsync(IEnumerable providerOrganizations); Task> GetManyDetailsByProviderAsync(Guid providerId); Task GetByOrganizationId(Guid organizationId); } diff --git a/src/Core/Services/IEventService.cs b/src/Core/Services/IEventService.cs index e76d08630f..2288d1f926 100644 --- a/src/Core/Services/IEventService.cs +++ b/src/Core/Services/IEventService.cs @@ -25,6 +25,7 @@ public interface IEventService Task LogProviderUserEventAsync(ProviderUser providerUser, EventType type, DateTime? date = null); Task LogProviderUsersEventAsync(IEnumerable<(ProviderUser, EventType, DateTime?)> events); Task LogProviderOrganizationEventAsync(ProviderOrganization providerOrganization, EventType type, DateTime? date = null); + Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events); Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, DateTime? date = null); Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, EventSystemUser systemUser, DateTime? date = null); Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null); diff --git a/src/Core/Services/IProviderService.cs b/src/Core/Services/IProviderService.cs index 00426c2e72..a55eb8e1e8 100644 --- a/src/Core/Services/IProviderService.cs +++ b/src/Core/Services/IProviderService.cs @@ -20,6 +20,7 @@ public interface IProviderService Guid deletingUserId); Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key); + Task AddOrganizationsToReseller(Guid providerId, IEnumerable organizationIds); Task CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user); Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId); diff --git a/src/Core/Services/Implementations/EventService.cs b/src/Core/Services/Implementations/EventService.cs index e599c1d304..96bdfe4500 100644 --- a/src/Core/Services/Implementations/EventService.cs +++ b/src/Core/Services/Implementations/EventService.cs @@ -332,26 +332,38 @@ public class EventService : IEventService public async Task LogProviderOrganizationEventAsync(ProviderOrganization providerOrganization, EventType type, DateTime? date = null) + { + await LogProviderOrganizationEventsAsync(new[] { (providerOrganization, type, date) }); + } + + public async Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events) { var providerAbilities = await _applicationCacheService.GetProviderAbilitiesAsync(); - if (!CanUseProviderEvents(providerAbilities, providerOrganization.ProviderId)) + var eventMessages = new List(); + foreach (var (providerOrganization, type, date) in events) { - return; + if (!CanUseProviderEvents(providerAbilities, providerOrganization.ProviderId)) + { + continue; + } + + var e = new EventMessage(_currentContext) + { + ProviderId = providerOrganization.ProviderId, + ProviderOrganizationId = providerOrganization.Id, + Type = type, + ActingUserId = _currentContext?.UserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + + eventMessages.Add(e); } - var e = new EventMessage(_currentContext) - { - ProviderId = providerOrganization.ProviderId, - ProviderOrganizationId = providerOrganization.Id, - Type = type, - ActingUserId = _currentContext?.UserId, - Date = date.GetValueOrDefault(DateTime.UtcNow) - }; - await _eventWriteService.CreateAsync(e); + await _eventWriteService.CreateManyAsync(eventMessages); } public async Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, - DateTime? date = null) + DateTime? date = null) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); if (!CanUseEvents(orgAbilities, organizationDomain.OrganizationId)) diff --git a/src/Core/Services/NoopImplementations/NoopEventService.cs b/src/Core/Services/NoopImplementations/NoopEventService.cs index c773c6a26e..9eaefdab3a 100644 --- a/src/Core/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/Services/NoopImplementations/NoopEventService.cs @@ -70,8 +70,13 @@ public class NoopEventService : IEventService return Task.FromResult(0); } + public Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events) + { + return Task.FromResult(0); + } + public Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, - DateTime? date = null) + DateTime? date = null) { return Task.FromResult(0); } diff --git a/src/Core/Services/NoopImplementations/NoopProviderService.cs b/src/Core/Services/NoopImplementations/NoopProviderService.cs index c197ced47d..c6c98430d0 100644 --- a/src/Core/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/Services/NoopImplementations/NoopProviderService.cs @@ -25,6 +25,8 @@ public class NoopProviderService : IProviderService public Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key) => throw new NotImplementedException(); + public Task AddOrganizationsToReseller(Guid providerId, IEnumerable organizationIds) => throw new NotImplementedException(); + public Task CreateOrganizationAsync(Guid providerId, OrganizationSignup organizationSignup, string clientOwnerEmail, User user) => throw new NotImplementedException(); public Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId) => throw new NotImplementedException(); diff --git a/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs index 52f486212f..8a3f30448a 100644 --- a/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/OrganizationRepository.cs @@ -134,4 +134,18 @@ public class OrganizationRepository : Repository, IOrganizat return selfHostOrganization; } } + + public async Task> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[Organization_UnassignedToProviderSearch]", + new { Name = name, OwnerEmail = ownerEmail, Skip = skip, Take = take }, + commandType: CommandType.StoredProcedure, + commandTimeout: 120); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.Dapper/Repositories/ProviderOrganizationRepository.cs b/src/Infrastructure.Dapper/Repositories/ProviderOrganizationRepository.cs index f77a724f93..be0ea6db26 100644 --- a/src/Infrastructure.Dapper/Repositories/ProviderOrganizationRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/ProviderOrganizationRepository.cs @@ -18,6 +18,48 @@ public class ProviderOrganizationRepository : Repository> CreateManyAsync(IEnumerable providerOrganizations) + { + var entities = providerOrganizations.ToList(); + + if (!entities.Any()) + { + return default; + } + + foreach (var providerOrganization in entities) + { + providerOrganization.SetNewId(); + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) + { + bulkCopy.DestinationTableName = "[dbo].[ProviderOrganization]"; + var dataTable = BuildProviderOrganizationsTable(bulkCopy, entities); + await bulkCopy.WriteToServerAsync(dataTable); + } + + transaction.Commit(); + + return entities.ToList(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task> GetManyDetailsByProviderAsync(Guid providerId) { using (var connection = new SqlConnection(ConnectionString)) @@ -43,4 +85,56 @@ public class ProviderOrganizationRepository : Repository providerOrganizations) + { + var po = providerOrganizations.FirstOrDefault(); + if (po == null) + { + throw new ApplicationException("Must have some ProviderOrganizations to bulk import."); + } + + var providerOrganizationsTable = new DataTable("ProviderOrganizationDataTable"); + + var idColumn = new DataColumn(nameof(po.Id), typeof(Guid)); + providerOrganizationsTable.Columns.Add(idColumn); + var providerIdColumn = new DataColumn(nameof(po.ProviderId), typeof(Guid)); + providerOrganizationsTable.Columns.Add(providerIdColumn); + var organizationIdColumn = new DataColumn(nameof(po.OrganizationId), typeof(Guid)); + providerOrganizationsTable.Columns.Add(organizationIdColumn); + var keyColumn = new DataColumn(nameof(po.Key), typeof(string)); + providerOrganizationsTable.Columns.Add(keyColumn); + var settingsColumn = new DataColumn(nameof(po.Settings), typeof(string)); + providerOrganizationsTable.Columns.Add(settingsColumn); + var creationDateColumn = new DataColumn(nameof(po.CreationDate), po.CreationDate.GetType()); + providerOrganizationsTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(po.RevisionDate), po.RevisionDate.GetType()); + providerOrganizationsTable.Columns.Add(revisionDateColumn); + + foreach (DataColumn col in providerOrganizationsTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + providerOrganizationsTable.PrimaryKey = keys; + + foreach (var providerOrganization in providerOrganizations) + { + var row = providerOrganizationsTable.NewRow(); + + row[idColumn] = providerOrganization.Id; + row[providerIdColumn] = providerOrganization.ProviderId; + row[organizationIdColumn] = providerOrganization.OrganizationId; + row[keyColumn] = providerOrganization.Key; + row[settingsColumn] = providerOrganization.Settings; + row[creationDateColumn] = providerOrganization.CreationDate; + row[revisionDateColumn] = providerOrganization.RevisionDate; + + providerOrganizationsTable.Rows.Add(row); + } + + return providerOrganizationsTable; + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs index 7f0028d25b..4627c56186 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs @@ -91,6 +91,32 @@ public class OrganizationRepository : Repository> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = from o in dbContext.Organizations + where o.PlanType >= PlanType.TeamsMonthly && o.PlanType <= PlanType.EnterpriseAnnually && + !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) && + (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%")) + select o; + + if (!string.IsNullOrWhiteSpace(ownerEmail)) + { + query = from o in query + join ou in dbContext.OrganizationUsers + on o.Id equals ou.OrganizationId + join u in dbContext.Users + on ou.UserId equals u.Id + where u.Email == ownerEmail && ou.Type == OrganizationUserType.Owner + select o; + } + + return await query.OrderByDescending(o => o.CreationDate).Skip(skip).Take(take).ToArrayAsync(); + } + } + public async Task UpdateStorageAsync(Guid id) { await OrganizationUpdateStorage(id); diff --git a/src/Infrastructure.EntityFramework/Repositories/ProviderOrganizationRepository.cs b/src/Infrastructure.EntityFramework/Repositories/ProviderOrganizationRepository.cs index 5d17d38bbf..7e7082dd99 100644 --- a/src/Infrastructure.EntityFramework/Repositories/ProviderOrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/ProviderOrganizationRepository.cs @@ -15,6 +15,30 @@ public class ProviderOrganizationRepository : : base(serviceScopeFactory, mapper, context => context.ProviderOrganizations) { } + public async Task> CreateManyAsync(IEnumerable providerOrganizations) + { + var entities = providerOrganizations.ToList(); + + if (!entities.Any()) + { + return default; + } + + foreach (var providerOrganization in entities) + { + providerOrganization.SetNewId(); + } + + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + await dbContext.AddRangeAsync(entities); + await dbContext.SaveChangesAsync(); + } + + return entities; + } + public async Task> GetManyDetailsByProviderAsync(Guid providerId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 95422a4a60..6ae3559106 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -264,6 +264,7 @@ + diff --git a/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql b/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql new file mode 100644 index 0000000000..18fe251433 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql @@ -0,0 +1,45 @@ +CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch] + @Name NVARCHAR(50), + @OwnerEmail NVARCHAR(256), + @Skip INT = 0, + @Take INT = 25 +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%' + + IF @OwnerEmail IS NOT NULL + BEGIN + SELECT + O.* + FROM + [dbo].[OrganizationView] O + INNER JOIN + [dbo].[OrganizationUser] OU ON O.[Id] = OU.[OrganizationId] + INNER JOIN + [dbo].[User] U ON U.[Id] = OU.[UserId] + WHERE + O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations + AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) + AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) + AND (U.[Email] = @OwnerEmail) + ORDER BY O.[CreationDate] DESC + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY + END + ELSE + BEGIN + SELECT + O.* + FROM + [dbo].[OrganizationView] O + WHERE + O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations + AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) + AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) + ORDER BY O.[CreationDate] DESC + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY + END +END \ No newline at end of file diff --git a/test/Core.Test/Services/EventServiceTests.cs b/test/Core.Test/Services/EventServiceTests.cs index 8454f05f5f..2cda759b15 100644 --- a/test/Core.Test/Services/EventServiceTests.cs +++ b/test/Core.Test/Services/EventServiceTests.cs @@ -214,4 +214,40 @@ public class EventServiceTests await sutProvider.GetDependency().Received(1).CreateManyAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expected, new[] { "IdempotencyId" }))); } + + [Theory, BitAutoData] + public async Task LogProviderOrganizationEventsAsync_LogsRequiredInfo(Provider provider, ICollection providerOrganizations, EventType eventType, DateTime date, + Guid actingUserId, Guid providerId, string ipAddress, DeviceType deviceType, SutProvider sutProvider) + { + foreach (var providerOrganization in providerOrganizations) + { + providerOrganization.ProviderId = provider.Id; + } + + var providerAbilities = new Dictionary() + { + { provider.Id, new ProviderAbility() { UseEvents = true, Enabled = true } } + }; + sutProvider.GetDependency().GetProviderAbilitiesAsync().Returns(providerAbilities); + sutProvider.GetDependency().UserId.Returns(actingUserId); + sutProvider.GetDependency().IpAddress.Returns(ipAddress); + sutProvider.GetDependency().DeviceType.Returns(deviceType); + sutProvider.GetDependency().ProviderIdForOrg(Arg.Any()).Returns(providerId); + + await sutProvider.Sut.LogProviderOrganizationEventsAsync(providerOrganizations.Select(po => (po, eventType, (DateTime?)date))); + + var expected = providerOrganizations.Select(po => + new EventMessage() + { + DeviceType = deviceType, + IpAddress = ipAddress, + ProviderId = provider.Id, + ProviderOrganizationId = po.Id, + Type = eventType, + ActingUserId = actingUserId, + Date = date + }).ToList(); + + await sutProvider.GetDependency().Received(1).CreateManyAsync(Arg.Is(AssertHelper.AssertPropertyEqual(expected, new[] { "IdempotencyId" }))); + } } diff --git a/test/Infrastructure.EFIntegration.Test/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Repositories/OrganizationRepositoryTests.cs index 04e314d560..40732709b4 100644 --- a/test/Infrastructure.EFIntegration.Test/Repositories/OrganizationRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Repositories/OrganizationRepositoryTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data.Organizations; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Test.AutoFixture.Attributes; using Bit.Infrastructure.EFIntegration.Test.AutoFixture; using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers; @@ -146,4 +148,42 @@ public class OrganizationRepositoryTests list.Concat(await sqlOrganizationRepo.GetManyAbilitiesAsync()); Assert.True(list.All(x => x.GetType() == typeof(OrganizationAbility))); } + + [CiSkippedTheory, EfOrganizationUserAutoData] + public async void SearchUnassignedAsync_Works(OrganizationUser orgUser, User user, Organization org, + List efOrgUserRepos, List efOrgRepos, List efUserRepos, + SqlRepo.OrganizationUserRepository sqlOrgUserRepo, SqlRepo.OrganizationRepository sqlOrgRepo, SqlRepo.UserRepository sqlUserRepo) + { + orgUser.Type = OrganizationUserType.Owner; + org.PlanType = PlanType.EnterpriseAnnually; + + var efList = new List(); + foreach (var efOrgUserRepo in efOrgUserRepos) + { + var i = efOrgUserRepos.IndexOf(efOrgUserRepo); + var postEfUser = await efUserRepos[i].CreateAsync(user); + var postEfOrg = await efOrgRepos[i].CreateAsync(org); + efOrgUserRepo.ClearChangeTracking(); + + orgUser.UserId = postEfUser.Id; + orgUser.OrganizationId = postEfOrg.Id; + await efOrgUserRepo.CreateAsync(orgUser); + efOrgUserRepo.ClearChangeTracking(); + + efList.AddRange(await efOrgRepos[i].SearchUnassignedToProviderAsync(org.Name, user.Email, 0, 10)); + } + + var postSqlUser = await sqlUserRepo.CreateAsync(user); + var postSqlOrg = await sqlOrgRepo.CreateAsync(org); + + orgUser.UserId = postSqlUser.Id; + orgUser.OrganizationId = postSqlOrg.Id; + await sqlOrgUserRepo.CreateAsync(orgUser); + var sqlResult = await sqlOrgRepo.SearchUnassignedToProviderAsync(org.Name, user.Email, 0, 10); + + Assert.Equal(efOrgRepos.Count, efList.Count); + Assert.True(efList.All(o => o.Name == org.Name)); + Assert.Equal(1, sqlResult.Count); + Assert.True(sqlResult.All(o => o.Name == org.Name)); + } } diff --git a/util/Migrator/DbScripts/2023-03-22_00_ProviderAddExistingOrganizations.sql b/util/Migrator/DbScripts/2023-03-22_00_ProviderAddExistingOrganizations.sql new file mode 100644 index 0000000000..e644c99347 --- /dev/null +++ b/util/Migrator/DbScripts/2023-03-22_00_ProviderAddExistingOrganizations.sql @@ -0,0 +1,53 @@ +-- Drop existing SPROC +IF OBJECT_ID('[dbo].[Organization_UnassignedToProviderSearch]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_UnassignedToProviderSearch] +END +GO + +CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch] + @Name NVARCHAR(50), + @OwnerEmail NVARCHAR(256), + @Skip INT = 0, + @Take INT = 25 +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%' + + IF @OwnerEmail IS NOT NULL + BEGIN + SELECT + O.* + FROM + [dbo].[OrganizationView] O + INNER JOIN + [dbo].[OrganizationUser] OU ON O.[Id] = OU.[OrganizationId] + INNER JOIN + [dbo].[User] U ON U.[Id] = OU.[UserId] + WHERE + O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations + AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) + AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) + AND (U.[Email] = @OwnerEmail) + ORDER BY O.[CreationDate] DESC + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY + END + ELSE + BEGIN + SELECT + O.* + FROM + [dbo].[OrganizationView] O + WHERE + O.[PlanType] >= 8 AND O.[PlanType] <= 11 -- Get 'Team' and 'Enterprise' Organizations + AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) + AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) + ORDER BY O.[CreationDate] DESC + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY + END +END +GO \ No newline at end of file