1
0
mirror of https://github.com/bitwarden/server synced 2026-01-07 11:03:37 +00:00

Add CQRS and caching support for OrganizationIntegrationConfigurations (#6690)

This commit is contained in:
Brant DeBow
2025-12-12 11:52:32 -05:00
committed by GitHub
parent 3de2f98681
commit 72c8967937
23 changed files with 1676 additions and 1105 deletions

View File

@@ -1,8 +1,8 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -12,8 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller
ICreateOrganizationIntegrationConfigurationCommand createCommand,
IUpdateOrganizationIntegrationConfigurationCommand updateCommand,
IDeleteOrganizationIntegrationConfigurationCommand deleteCommand,
IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller
{
[HttpGet("")]
public async Task<List<OrganizationIntegrationConfigurationResponseModel>> GetAsync(
@@ -24,13 +26,8 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId);
var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId);
return configurations
.Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration))
.ToList();
@@ -46,19 +43,11 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
var configuration = model.ToOrganizationIntegrationConfiguration(integrationId);
var created = await createCommand.CreateAsync(organizationId, integrationId, configuration);
return new OrganizationIntegrationConfigurationResponseModel(created);
}
[HttpPut("{configurationId:guid}")]
@@ -72,26 +61,11 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
var configuration = model.ToOrganizationIntegrationConfiguration(integrationId);
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration);
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(updated);
}
[HttpDelete("{configurationId:guid}")]
@@ -101,19 +75,8 @@ public class OrganizationIntegrationConfigurationController(
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId);
}
[HttpPost("{configurationId:guid}/delete")]

View File

@@ -1,6 +1,4 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
@@ -16,38 +14,6 @@ public class OrganizationIntegrationConfigurationRequestModel
public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType)
{
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<SlackIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<WebhookIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Hec:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Datadog:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Teams:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
default:
return false;
}
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId)
{
return new OrganizationIntegrationConfiguration()
@@ -59,50 +25,4 @@ public class OrganizationIntegrationConfigurationRequestModel
Template = Template
};
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration)
{
currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType;
currentConfiguration.Filters = Filters;
currentConfiguration.Template = Template;
return currentConfiguration;
}
private bool IsConfigurationValid<T>()
{
if (string.IsNullOrWhiteSpace(Configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(Configuration);
return config is not null;
}
catch
{
return false;
}
}
private bool IsFiltersValid()
{
if (Filters is null)
{
return true;
}
try
{
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(Filters);
return filters is not null;
}
catch
{
return false;
}
}
}

View File

@@ -1,5 +1,8 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -20,8 +23,12 @@ public static class EventIntegrationsServiceCollectionExtensions
// This is idempotent for the same named cache, so it's safe to call.
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
// Add Validator
services.TryAddSingleton<IOrganizationIntegrationConfigurationValidator, OrganizationIntegrationConfigurationValidator>();
// Add all commands/queries
services.AddOrganizationIntegrationCommandsQueries();
services.AddOrganizationIntegrationConfigurationCommandsQueries();
return services;
}
@@ -35,4 +42,14 @@ public static class EventIntegrationsServiceCollectionExtensions
return services;
}
internal static IServiceCollection AddOrganizationIntegrationConfigurationCommandsQueries(this IServiceCollection services)
{
services.TryAddScoped<ICreateOrganizationIntegrationConfigurationCommand, CreateOrganizationIntegrationConfigurationCommand>();
services.TryAddScoped<IUpdateOrganizationIntegrationConfigurationCommand, UpdateOrganizationIntegrationConfigurationCommand>();
services.TryAddScoped<IDeleteOrganizationIntegrationConfigurationCommand, DeleteOrganizationIntegrationConfigurationCommand>();
services.TryAddScoped<IGetOrganizationIntegrationConfigurationsQuery, GetOrganizationIntegrationConfigurationsQuery>();
return services;
}
}

View File

@@ -0,0 +1,64 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Command implementation for creating organization integration configurations with validation and cache invalidation support.
/// </summary>
public class CreateOrganizationIntegrationConfigurationCommand(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache,
IOrganizationIntegrationConfigurationValidator validator)
: ICreateOrganizationIntegrationConfigurationCommand
{
public async Task<OrganizationIntegrationConfiguration> CreateAsync(
Guid organizationId,
Guid integrationId,
OrganizationIntegrationConfiguration configuration)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!validator.ValidateConfiguration(integration.Type, configuration))
{
throw new BadRequestException(
$"Invalid Configuration and/or Filters for integration type {integration.Type}");
}
var created = await configurationRepository.CreateAsync(configuration);
// Invalidate the cached configuration details
// Even though this is a new record, the cache could hold a stale empty list for this
if (created.EventType == null)
{
// Wildcard configuration - invalidate all cached results for this org/integration
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
}
else
{
// Specific event type - only invalidate that specific cache entry
await cache.RemoveAsync(
EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integration.Type,
eventType: created.EventType.Value
));
}
return created;
}
}

View File

@@ -0,0 +1,54 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Command implementation for deleting organization integration configurations with cache invalidation support.
/// </summary>
public class DeleteOrganizationIntegrationConfigurationCommand(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache)
: IDeleteOrganizationIntegrationConfigurationCommand
{
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await configurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await configurationRepository.DeleteAsync(configuration);
if (configuration.EventType == null)
{
// Wildcard configuration - invalidate all cached results for this org/integration
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
}
else
{
// Specific event type - only invalidate that specific cache entry
await cache.RemoveAsync(
EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integration.Type,
eventType: configuration.EventType.Value
));
}
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Query implementation for retrieving organization integration configurations.
/// </summary>
public class GetOrganizationIntegrationConfigurationsQuery(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository)
: IGetOrganizationIntegrationConfigurationsQuery
{
public async Task<List<OrganizationIntegrationConfiguration>> GetManyByIntegrationAsync(
Guid organizationId,
Guid integrationId)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configurations = await configurationRepository.GetManyByIntegrationAsync(integrationId);
return configurations.ToList();
}
}

View File

@@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
/// <summary>
/// Command interface for creating organization integration configurations.
/// </summary>
public interface ICreateOrganizationIntegrationConfigurationCommand
{
/// <summary>
/// Creates a new configuration for an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration.</param>
/// <param name="configuration">The configuration to create.</param>
/// <returns>The created configuration.</returns>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
/// or does not belong to the specified organization.</exception>
/// <exception cref="Exceptions.BadRequestException">Thrown when the configuration or filters
/// are invalid for the integration type.</exception>
Task<OrganizationIntegrationConfiguration> CreateAsync(Guid organizationId, Guid integrationId, OrganizationIntegrationConfiguration configuration);
}

View File

@@ -0,0 +1,19 @@
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
/// <summary>
/// Command interface for deleting organization integration configurations.
/// </summary>
public interface IDeleteOrganizationIntegrationConfigurationCommand
{
/// <summary>
/// Deletes a configuration from an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration.</param>
/// <param name="configurationId">The unique identifier of the configuration to delete.</param>
/// <exception cref="Exceptions.NotFoundException">
/// Thrown when the integration or configuration does not exist,
/// or the integration does not belong to the specified organization,
/// or the configuration does not belong to the specified integration.</exception>
Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId);
}

View File

@@ -0,0 +1,19 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
/// <summary>
/// Query interface for retrieving organization integration configurations.
/// </summary>
public interface IGetOrganizationIntegrationConfigurationsQuery
{
/// <summary>
/// Retrieves all configurations for a specific organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration.</param>
/// <returns>A list of configurations associated with the integration.</returns>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
/// or does not belong to the specified organization.</exception>
Task<List<OrganizationIntegrationConfiguration>> GetManyByIntegrationAsync(Guid organizationId, Guid integrationId);
}

View File

@@ -0,0 +1,25 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
/// <summary>
/// Command interface for updating organization integration configurations.
/// </summary>
public interface IUpdateOrganizationIntegrationConfigurationCommand
{
/// <summary>
/// Updates an existing configuration for an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration.</param>
/// <param name="configurationId">The unique identifier of the configuration to update.</param>
/// <param name="updatedConfiguration">The updated configuration data.</param>
/// <returns>The updated configuration.</returns>
/// <exception cref="Exceptions.NotFoundException">
/// Thrown when the integration or the configuration does not exist,
/// or the integration does not belong to the specified organization,
/// or the configuration does not belong to the specified integration.</exception>
/// <exception cref="Exceptions.BadRequestException">Thrown when the configuration or filters
/// are invalid for the integration type.</exception>
Task<OrganizationIntegrationConfiguration> UpdateAsync(Guid organizationId, Guid integrationId, Guid configurationId, OrganizationIntegrationConfiguration updatedConfiguration);
}

View File

@@ -0,0 +1,82 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
/// <summary>
/// Command implementation for updating organization integration configurations with validation and cache invalidation support.
/// </summary>
public class UpdateOrganizationIntegrationConfigurationCommand(
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository configurationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache,
IOrganizationIntegrationConfigurationValidator validator)
: IUpdateOrganizationIntegrationConfigurationCommand
{
public async Task<OrganizationIntegrationConfiguration> UpdateAsync(
Guid organizationId,
Guid integrationId,
Guid configurationId,
OrganizationIntegrationConfiguration updatedConfiguration)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await configurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
if (!validator.ValidateConfiguration(integration.Type, updatedConfiguration))
{
throw new BadRequestException($"Invalid Configuration and/or Filters for integration type {integration.Type}");
}
updatedConfiguration.Id = configuration.Id;
updatedConfiguration.CreationDate = configuration.CreationDate;
await configurationRepository.ReplaceAsync(updatedConfiguration);
// If either old or new EventType is null (wildcard), invalidate all cached results
// for the specific integration
if (configuration.EventType == null || updatedConfiguration.EventType == null)
{
// Wildcard involved - invalidate all cached results for this org/integration
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
return updatedConfiguration;
}
// Both are specific event types - invalidate specific cache entries
await cache.RemoveAsync(
EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integration.Type,
eventType: configuration.EventType.Value
));
// If event type changed, also clear the new event type's cache
if (configuration.EventType != updatedConfiguration.EventType)
{
await cache.RemoveAsync(
EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integration.Type,
eventType: updatedConfiguration.EventType.Value
));
}
return updatedConfiguration;
}
}

View File

@@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Services;
public interface IOrganizationIntegrationConfigurationValidator
{
/// <summary>
/// Validates that the configuration is valid for the given integration type. The configuration must
/// include a Configuration that is valid for the type, valid Filters, and a non-empty Template
/// to pass validation.
/// </summary>
/// <param name="integrationType">The type of integration</param>
/// <param name="configuration">The OrganizationIntegrationConfiguration to validate</param>
/// <returns>True if valid, false otherwise</returns>
bool ValidateConfiguration(IntegrationType integrationType, OrganizationIntegrationConfiguration configuration);
}

View File

@@ -0,0 +1,76 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.Services;
public class OrganizationIntegrationConfigurationValidator : IOrganizationIntegrationConfigurationValidator
{
public bool ValidateConfiguration(IntegrationType integrationType,
OrganizationIntegrationConfiguration configuration)
{
// Validate template is present
if (string.IsNullOrWhiteSpace(configuration.Template))
{
return false;
}
// If Filters are present, they must be valid
if (!IsFiltersValid(configuration.Filters))
{
return false;
}
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return IsConfigurationValid<SlackIntegrationConfiguration>(configuration.Configuration);
case IntegrationType.Webhook:
return IsConfigurationValid<WebhookIntegrationConfiguration>(configuration.Configuration);
case IntegrationType.Hec:
case IntegrationType.Datadog:
case IntegrationType.Teams:
return configuration.Configuration is null;
default:
return false;
}
}
private static bool IsConfigurationValid<T>(string? configuration)
{
if (string.IsNullOrWhiteSpace(configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(configuration);
return config is not null;
}
catch
{
return false;
}
}
private static bool IsFiltersValid(string? filters)
{
if (filters is null)
{
return true;
}
try
{
var filterGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(filters);
return filterGroup is not null;
}
catch
{
return false;
}
}
}

View File

@@ -55,16 +55,16 @@ public static class EventIntegrationsCacheConstants
/// Builds a deterministic cache key for an organization's integration configuration details
/// <see cref="OrganizationIntegrationConfigurationDetails"/>.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization to which the user belongs.</param>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationType">The <see cref="IntegrationType"/> of the integration.</param>
/// <param name="eventType">The <see cref="EventType"/> of the event configured. Can be null to apply to all events.</param>
/// <param name="eventType">The specific <see cref="EventType"/> of the event configured.</param>
/// <returns>
/// A cache key for the configuration details.
/// </returns>
public static string BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
Guid organizationId,
IntegrationType integrationType,
EventType? eventType
EventType eventType
) => $"OrganizationIntegrationConfigurationDetails:{organizationId:N}:{integrationType}:{eventType}";
/// <summary>