From 5fc7f4700c642dcaaa56a716c803787e05608dde Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:41:08 -0400 Subject: [PATCH] [PM-17562] Add in-memory cache for event integrations (#6085) * [PM-17562] Add in-memory cache for event integrations * Fix Sql error * Fix failing test * Add additional tests for new cache service * PR suggestions addressed --- ...nizationIntegrationConfigurationDetails.cs | 1 + ...ationIntegrationConfigurationRepository.cs | 2 + .../IIntegrationConfigurationDetailsCache.cs | 14 ++ .../EventIntegrationHandler.cs | 4 +- ...grationConfigurationDetailsCacheService.cs | 73 ++++++++++ .../EventIntegrations/README.md | 29 ++++ src/Core/Settings/GlobalSettings.cs | 1 + ...ationIntegrationConfigurationRepository.cs | 12 ++ ...ationIntegrationConfigurationRepository.cs | 10 ++ ...tTypeOrganizationIdIntegrationTypeQuery.cs | 5 +- ...rationConfigurationDetailsReadManyQuery.cs | 28 ++++ .../Utilities/ServiceCollectionExtensions.cs | 13 +- ...tegrationConfigurationDetails_ReadMany.sql | 11 ++ .../Services/EventIntegrationHandlerTests.cs | 6 +- ...onConfigurationDetailsCacheServiceTests.cs | 133 ++++++++++++++++++ ...izationIntegrationConfigurationDetails.sql | 11 ++ 16 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql create mode 100644 test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs create mode 100644 util/Migrator/DbScripts/2025-07-11_00_AddReadManyOrganizationIntegrationConfigurationDetails.sql diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs index b5d224c012..a184e9ac8e 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs @@ -8,6 +8,7 @@ namespace Bit.Core.Models.Data.Organizations; public class OrganizationIntegrationConfigurationDetails { public Guid Id { get; set; } + public Guid OrganizationId { get; set; } public Guid OrganizationIntegrationId { get; set; } public IntegrationType IntegrationType { get; set; } public EventType EventType { get; set; } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs index 516918fff9..53159c98e7 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -10,4 +10,6 @@ public interface IOrganizationIntegrationConfigurationRepository : IRepository> GetAllConfigurationDetailsAsync(); } diff --git a/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs new file mode 100644 index 0000000000..ad27429112 --- /dev/null +++ b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs @@ -0,0 +1,14 @@ +#nullable enable + +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services; + +public interface IIntegrationConfigurationDetailsCache +{ + List GetConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType eventType); +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 3ffd08edd2..9cd789be76 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -14,7 +14,7 @@ public class EventIntegrationHandler( IntegrationType integrationType, IEventIntegrationPublisher eventIntegrationPublisher, IIntegrationFilterService integrationFilterService, - IOrganizationIntegrationConfigurationRepository configurationRepository, + IIntegrationConfigurationDetailsCache configurationCache, IUserRepository userRepository, IOrganizationRepository organizationRepository, ILogger> logger) @@ -27,7 +27,7 @@ public class EventIntegrationHandler( return; } - var configurations = await configurationRepository.GetConfigurationDetailsAsync( + var configurations = configurationCache.GetConfigurationDetails( organizationId, integrationType, eventMessage.Type); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs new file mode 100644 index 0000000000..4e4657f824 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache +{ + private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType EventType); + private readonly IOrganizationIntegrationConfigurationRepository _repository; + private readonly ILogger _logger; + private readonly TimeSpan _refreshInterval; + private Dictionary> _cache = new(); + + public IntegrationConfigurationDetailsCacheService( + IOrganizationIntegrationConfigurationRepository repository, + GlobalSettings globalSettings, + ILogger logger + ) + { + _repository = repository; + _logger = logger; + _refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes); + } + + public List GetConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType eventType) + { + var key = new IntegrationCacheKey(organizationId, integrationType, eventType); + return _cache.TryGetValue(key, out var value) + ? value + : new List(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await RefreshAsync(); + + var timer = new PeriodicTimer(_refreshInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RefreshAsync(); + } + } + + internal async Task RefreshAsync() + { + var stopwatch = Stopwatch.StartNew(); + try + { + var newCache = (await _repository.GetAllConfigurationDetailsAsync()) + .GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType)) + .ToDictionary(g => g.Key, g => g.ToList()); + _cache = newCache; + + stopwatch.Stop(); + _logger.LogInformation( + "[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms", + newCache.Count, + stopwatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index b2327b0f75..f48dee8aad 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -290,6 +290,35 @@ graph TD C1 -->|Has many| B1_2[IntegrationFilterRule] C1 -->|Can contain| C2[IntegrationFilterGroup...] ``` +## Caching + +To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary +with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database +query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. + +By loading all configurations into memory on a fixed interval, we ensure: + +- Consistent performance for reads. +- Reduced database pressure. +- Predictable refresh timing, independent of event activity. + +### Architecture / Design + +- The cache is read-only for consumers. It is only updated in bulk by a background refresh process. +- The cache is fully replaced on each refresh to avoid locking or partial state. +- Reads return a `List` for a given key or an empty list if no + match exists. +- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving + the last known good state until the update replaces the whole cache. + +### Background Refresh + +A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and: + +- Loads all configuration records at application startup. +- Refreshes the cache on a configurable interval. +- Logs timing and entry count on success. +- Logs exceptions on failure without disrupting application flow. # Building a new integration diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index e4f308c358..e49ef4a7f2 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -287,6 +287,7 @@ public class GlobalSettings : IGlobalSettings { public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); + public int IntegrationCacheRefreshIntervalMinutes { get; set; } = 10; public class AzureServiceBusSettings { diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index f3227dfd22..5a7e1ce152 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -40,4 +40,16 @@ public class OrganizationIntegrationConfigurationRepository : Repository> GetAllConfigurationDetailsAsync() + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationIntegrationConfigurationDetails_ReadMany]", + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index f051830035..1e1dcd3ba4 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -30,4 +30,14 @@ public class OrganizationIntegrationConfigurationRepository : Repository> GetAllConfigurationDetailsAsync() + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationIntegrationConfigurationDetailsReadManyQuery(); + return await query.Run(dbContext).ToListAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs index c816b01a01..b4441c5084 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs @@ -1,4 +1,6 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -27,6 +29,7 @@ public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrgan select new OrganizationIntegrationConfigurationDetails() { Id = oic.Id, + OrganizationId = oi.OrganizationId, OrganizationIntegrationId = oic.OrganizationIntegrationId, IntegrationType = oi.Type, EventType = oic.EventType, diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs new file mode 100644 index 0000000000..8141292c81 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs @@ -0,0 +1,28 @@ +#nullable enable + +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class OrganizationIntegrationConfigurationDetailsReadManyQuery : IQuery +{ + public IQueryable Run(DatabaseContext dbContext) + { + var query = from oic in dbContext.OrganizationIntegrationConfigurations + join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id into oioic + from oi in dbContext.OrganizationIntegrations + select new OrganizationIntegrationConfigurationDetails() + { + Id = oic.Id, + OrganizationId = oi.OrganizationId, + OrganizationIntegrationId = oic.OrganizationIntegrationId, + IntegrationType = oi.Type, + EventType = oic.EventType, + Configuration = oic.Configuration, + Filters = oic.Filters, + IntegrationConfiguration = oi.Configuration, + Template = oic.Template + }; + return query; + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 0bf09706a9..dad0bc230e 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -618,7 +618,7 @@ public static class ServiceCollectionExtensions integrationType, provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService>>())); @@ -652,6 +652,10 @@ public static class ServiceCollectionExtensions !CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName)) return services; + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -664,6 +668,7 @@ public static class ServiceCollectionExtensions integrationType: IntegrationType.Slack, globalSettings: globalSettings); + services.TryAddSingleton(TimeProvider.System); services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); services.AddAzureServiceBusIntegration( eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.WebhookEventSubscriptionName, @@ -711,7 +716,7 @@ public static class ServiceCollectionExtensions integrationType, provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService>>())); @@ -745,6 +750,10 @@ public static class ServiceCollectionExtensions return services; } + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql new file mode 100644 index 0000000000..9c65ef58af --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql @@ -0,0 +1,11 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadMany] +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationDetailsView] oic +END +GO diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index 2d1099db65..f038fe28ef 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -32,12 +32,12 @@ public class EventIntegrationHandlerTests private SutProvider> GetSutProvider( List configurations) { - var configurationRepository = Substitute.For(); - configurationRepository.GetConfigurationDetailsAsync(Arg.Any(), + var configurationCache = Substitute.For(); + configurationCache.GetConfigurationDetails(Arg.Any(), IntegrationType.Webhook, Arg.Any()).Returns(configurations); return new SutProvider>() - .SetDependency(configurationRepository) + .SetDependency(configurationCache) .SetDependency(_eventIntegrationPublisher) .SetDependency(IntegrationType.Webhook) .SetDependency(_logger) diff --git a/test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs new file mode 100644 index 0000000000..d24a5afa27 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/IntegrationConfigurationDetailsCacheServiceTests.cs @@ -0,0 +1,133 @@ +#nullable enable + +using System.Text.Json; +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 Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class IntegrationConfigurationDetailsCacheServiceTests +{ + private SutProvider GetSutProvider( + List configurations) + { + var configurationRepository = Substitute.For(); + configurationRepository.GetAllConfigurationDetailsAsync().Returns(configurations); + + return new SutProvider() + .SetDependency(configurationRepository) + .Create(); + } + + [Theory, BitAutoData] + public async Task GetConfigurationDetails_KeyExists_ReturnsExpectedList(OrganizationIntegrationConfigurationDetails config) + { + var sutProvider = GetSutProvider([config]); + await sutProvider.Sut.RefreshAsync(); + var result = sutProvider.Sut.GetConfigurationDetails( + config.OrganizationId, + config.IntegrationType, + config.EventType); + Assert.Single(result); + Assert.Same(config, result[0]); + } + + [Theory, BitAutoData] + public async Task GetConfigurationDetails_KeyMissing_ReturnsEmptyList(OrganizationIntegrationConfigurationDetails config) + { + var sutProvider = GetSutProvider([config]); + await sutProvider.Sut.RefreshAsync(); + var result = sutProvider.Sut.GetConfigurationDetails( + Guid.NewGuid(), + config.IntegrationType, + config.EventType); + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task GetConfigurationDetails_ReturnsCachedValue_EvenIfRepositoryChanges(OrganizationIntegrationConfigurationDetails config) + { + var sutProvider = GetSutProvider([config]); + await sutProvider.Sut.RefreshAsync(); + + var newConfig = JsonSerializer.Deserialize(JsonSerializer.Serialize(config)); + Assert.NotNull(newConfig); + newConfig.Template = "Changed"; + sutProvider.GetDependency().GetAllConfigurationDetailsAsync() + .Returns([newConfig]); + + var result = sutProvider.Sut.GetConfigurationDetails( + config.OrganizationId, + config.IntegrationType, + config.EventType); + Assert.Single(result); + Assert.NotEqual("Changed", result[0].Template); // should not yet pick up change from repository + + await sutProvider.Sut.RefreshAsync(); // Pick up changes + + result = sutProvider.Sut.GetConfigurationDetails( + config.OrganizationId, + config.IntegrationType, + config.EventType); + Assert.Single(result); + Assert.Equal("Changed", result[0].Template); // Should have the new value + } + + [Theory, BitAutoData] + public async Task RefreshAsync_GroupsByCompositeKey(OrganizationIntegrationConfigurationDetails config1) + { + var config2 = JsonSerializer.Deserialize( + JsonSerializer.Serialize(config1))!; + config2.Template = "Another"; + + var sutProvider = GetSutProvider([config1, config2]); + await sutProvider.Sut.RefreshAsync(); + + var results = sutProvider.Sut.GetConfigurationDetails( + config1.OrganizationId, + config1.IntegrationType, + config1.EventType); + + Assert.Equal(2, results.Count); + Assert.Contains(results, r => r.Template == config1.Template); + Assert.Contains(results, r => r.Template == config2.Template); + } + + [Theory, BitAutoData] + public async Task RefreshAsync_LogsInformationOnSuccess(OrganizationIntegrationConfigurationDetails config) + { + var sutProvider = GetSutProvider([config]); + await sutProvider.Sut.RefreshAsync(); + + sutProvider.GetDependency>().Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Refreshed successfully")), + null, + Arg.Any>()); + } + + [Fact] + public async Task RefreshAsync_OnException_LogsError() + { + var sutProvider = GetSutProvider([]); + sutProvider.GetDependency().GetAllConfigurationDetailsAsync() + .Throws(new Exception("Database failure")); + await sutProvider.Sut.RefreshAsync(); + + sutProvider.GetDependency>().Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Refresh failed")), + Arg.Any(), + Arg.Any>()); + } +} diff --git a/util/Migrator/DbScripts/2025-07-11_00_AddReadManyOrganizationIntegrationConfigurationDetails.sql b/util/Migrator/DbScripts/2025-07-11_00_AddReadManyOrganizationIntegrationConfigurationDetails.sql new file mode 100644 index 0000000000..475755b8eb --- /dev/null +++ b/util/Migrator/DbScripts/2025-07-11_00_AddReadManyOrganizationIntegrationConfigurationDetails.sql @@ -0,0 +1,11 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadMany] +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationDetailsView] oic +END +GO