mirror of
https://github.com/bitwarden/server
synced 2026-01-17 07:53:36 +00:00
Move all event integration code to Dirt (#6757)
* Move all event integration code to Dirt * Format to fix lint
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class OrganizationIntegration : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public IntegrationType Type { get; set; }
|
||||
public string? Configuration { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Entities;
|
||||
|
||||
public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationIntegrationId { get; set; }
|
||||
public EventType? EventType { get; set; }
|
||||
public string? Configuration { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
public string? Filters { get; set; }
|
||||
public void SetNewId() => Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
namespace Bit.Core.Enums;
|
||||
|
||||
public enum IntegrationType : int
|
||||
{
|
||||
CloudBillingSync = 1,
|
||||
Scim = 2,
|
||||
Slack = 3,
|
||||
Webhook = 4,
|
||||
Hec = 5,
|
||||
Datadog = 6,
|
||||
Teams = 7
|
||||
}
|
||||
|
||||
public static class IntegrationTypeExtensions
|
||||
{
|
||||
public static string ToRoutingKey(this IntegrationType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case IntegrationType.Slack:
|
||||
return "slack";
|
||||
case IntegrationType.Webhook:
|
||||
return "webhook";
|
||||
case IntegrationType.Hec:
|
||||
return "hec";
|
||||
case IntegrationType.Datadog:
|
||||
return "datadog";
|
||||
case IntegrationType.Teams:
|
||||
return "teams";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
|
||||
public enum OrganizationIntegrationStatus : int
|
||||
{
|
||||
NotApplicable,
|
||||
Invalid,
|
||||
Initiated,
|
||||
InProgress,
|
||||
Completed
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
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.Models.Data.EventIntegrations;
|
||||
using Bit.Core.AdminConsole.Models.Teams;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
using TableStorageRepos = Bit.Core.Repositories.TableStorage;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class EventIntegrationsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds all event integrations commands, queries, and required cache infrastructure.
|
||||
/// This method is idempotent and can be called multiple times safely.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEventIntegrationsCommandsQueries(
|
||||
this IServiceCollection services,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
// Ensure cache is registered first - commands depend on this keyed cache.
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers event write services based on available configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="globalSettings">The global settings containing event logging configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method registers the appropriate IEventWriteService implementation based on the available
|
||||
/// configuration, checking in the following priority order:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 1. Azure Service Bus - If all Azure Service Bus settings are present, registers
|
||||
/// EventIntegrationEventWriteService with AzureServiceBusService as the publisher
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 2. RabbitMQ - If all RabbitMQ settings are present, registers EventIntegrationEventWriteService with
|
||||
/// RabbitMqService as the publisher
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 3. Azure Queue Storage - If Events.ConnectionString is present, registers AzureQueueEventWriteService
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 4. Repository (Self-Hosted) - If SelfHosted is true, registers RepositoryEventWriteService
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 5. Noop - If none of the above are configured, registers NoopEventWriteService (no-op implementation)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (IsAzureServiceBusEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
|
||||
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
if (IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
|
||||
services.TryAddSingleton<IEventWriteService, EventIntegrationEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Events.QueueName))
|
||||
{
|
||||
services.TryAddSingleton<IEventWriteService, AzureQueueEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
services.TryAddSingleton<IEventWriteService, RepositoryEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IEventWriteService, NoopEventWriteService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Azure Service Bus-based event integration listeners and supporting infrastructure.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="globalSettings">The global settings containing Azure Service Bus configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If Azure Service Bus is not enabled (missing required settings), this method returns immediately
|
||||
/// without registering any services.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When Azure Service Bus is enabled, this method registers:
|
||||
/// - IAzureServiceBusService and IEventIntegrationPublisher implementations
|
||||
/// - Table Storage event repository
|
||||
/// - Azure Table Storage event handler
|
||||
/// - All event integration services via AddEventIntegrationServices
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method,
|
||||
/// as it is required to create the event integrations extended cache.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (!IsAzureServiceBusEnabled(globalSettings))
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IAzureServiceBusService, AzureServiceBusService>();
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, AzureServiceBusService>();
|
||||
services.TryAddSingleton<IEventRepository, TableStorageRepos.EventRepository>();
|
||||
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
|
||||
services.TryAddSingleton<AzureTableStorageEventHandler>();
|
||||
|
||||
services.AddEventIntegrationServices(globalSettings);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers RabbitMQ-based event integration listeners and supporting infrastructure.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="globalSettings">The global settings containing RabbitMQ configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If RabbitMQ is not enabled (missing required settings), this method returns immediately
|
||||
/// without registering any services.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When RabbitMQ is enabled, this method registers:
|
||||
/// - IRabbitMqService and IEventIntegrationPublisher implementations
|
||||
/// - Event repository handler
|
||||
/// - All event integration services via AddEventIntegrationServices
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// PREREQUISITE: Callers must ensure AddDistributedCache has been called before this method,
|
||||
/// as it is required to create the event integrations extended cache.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddRabbitMqListeners(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (!IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
return services;
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IRabbitMqService, RabbitMqService>();
|
||||
services.TryAddSingleton<IEventIntegrationPublisher, RabbitMqService>();
|
||||
services.TryAddSingleton<EventRepositoryHandler>();
|
||||
|
||||
services.AddEventIntegrationServices(globalSettings);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Slack integration services based on configuration settings.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="globalSettings">The global settings containing Slack configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// If all required Slack settings are configured (ClientId, ClientSecret, Scopes), registers the full SlackService,
|
||||
/// including an HttpClient for Slack API calls. Otherwise, registers a NoopSlackService that performs no operations.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
|
||||
{
|
||||
services.AddHttpClient(SlackService.HttpClientName);
|
||||
services.TryAddSingleton<ISlackService, SlackService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ISlackService, NoopSlackService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Microsoft Teams integration services based on configuration settings.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="globalSettings">The global settings containing Teams configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// If all required Teams settings are configured (ClientId, ClientSecret, Scopes), registers:
|
||||
/// - TeamsService and its interfaces (IBot, ITeamsService)
|
||||
/// - IBotFrameworkHttpAdapter with Teams credentials
|
||||
/// - HttpClient for Teams API calls
|
||||
/// Otherwise, registers a NoopTeamsService that performs no operations.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) &&
|
||||
CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes))
|
||||
{
|
||||
services.AddHttpClient(TeamsService.HttpClientName);
|
||||
services.TryAddSingleton<TeamsService>();
|
||||
services.TryAddSingleton<IBot>(sp => sp.GetRequiredService<TeamsService>());
|
||||
services.TryAddSingleton<ITeamsService>(sp => sp.GetRequiredService<TeamsService>());
|
||||
services.TryAddSingleton<IBotFrameworkHttpAdapter>(_ =>
|
||||
new BotFrameworkHttpAdapter(
|
||||
new TeamsBotCredentialProvider(
|
||||
clientId: globalSettings.Teams.ClientId,
|
||||
clientSecret: globalSettings.Teams.ClientSecret
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ITeamsService, NoopTeamsService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers event integration services including handlers, listeners, and supporting infrastructure.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="globalSettings">The global settings containing integration configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method orchestrates the registration of all event integration components based on the enabled
|
||||
/// message broker (Azure Service Bus or RabbitMQ). It is an internal method called by the public
|
||||
/// entry points AddAzureServiceBusListeners and AddRabbitMqListeners.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// NOTE: If both Azure Service Bus and RabbitMQ are configured, Azure Service Bus takes precedence. This means that
|
||||
/// Azure Service Bus listeners will be registered (and RabbitMQ listeners will NOT) even if this event is called
|
||||
/// from AddRabbitMqListeners when Azure Service Bus settings are configured.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// PREREQUISITE: Callers must ensure AddDistributedCache has been called before invoking this method.
|
||||
/// This method depends on distributed cache infrastructure being available for the keyed extended
|
||||
/// cache registration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered Services:
|
||||
/// - Keyed ExtendedCache for event integrations
|
||||
/// - Integration filter service
|
||||
/// - Integration handlers for Slack, Webhook, Hec, Datadog, and Teams
|
||||
/// - Hosted services for event and integration listeners (based on enabled message broker)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IServiceCollection AddEventIntegrationServices(this IServiceCollection services,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
// Add common services
|
||||
// NOTE: AddDistributedCache must be called by the caller before this method
|
||||
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
|
||||
services.TryAddSingleton<IIntegrationFilterService, IntegrationFilterService>();
|
||||
services.TryAddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("persistent");
|
||||
|
||||
// Add services in support of handlers
|
||||
services.AddSlackService(globalSettings);
|
||||
services.AddTeamsService(globalSettings);
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddHttpClient(WebhookIntegrationHandler.HttpClientName);
|
||||
services.AddHttpClient(DatadogIntegrationHandler.HttpClientName);
|
||||
|
||||
// Add integration handlers
|
||||
services.TryAddSingleton<IIntegrationHandler<SlackIntegrationConfigurationDetails>, SlackIntegrationHandler>();
|
||||
services.TryAddSingleton<IIntegrationHandler<WebhookIntegrationConfigurationDetails>, WebhookIntegrationHandler>();
|
||||
services.TryAddSingleton<IIntegrationHandler<DatadogIntegrationConfigurationDetails>, DatadogIntegrationHandler>();
|
||||
services.TryAddSingleton<IIntegrationHandler<TeamsIntegrationConfigurationDetails>, TeamsIntegrationHandler>();
|
||||
|
||||
var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings);
|
||||
var slackConfiguration = new SlackListenerConfiguration(globalSettings);
|
||||
var webhookConfiguration = new WebhookListenerConfiguration(globalSettings);
|
||||
var hecConfiguration = new HecListenerConfiguration(globalSettings);
|
||||
var datadogConfiguration = new DatadogListenerConfiguration(globalSettings);
|
||||
var teamsConfiguration = new TeamsListenerConfiguration(globalSettings);
|
||||
|
||||
if (IsAzureServiceBusEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
AzureServiceBusEventListenerService<RepositoryListenerConfiguration>>(provider =>
|
||||
new AzureServiceBusEventListenerService<RepositoryListenerConfiguration>(
|
||||
configuration: repositoryConfiguration,
|
||||
handler: provider.GetRequiredService<AzureTableStorageEventHandler>(),
|
||||
serviceBusService: provider.GetRequiredService<IAzureServiceBusService>(),
|
||||
serviceBusOptions: new ServiceBusProcessorOptions()
|
||||
{
|
||||
PrefetchCount = repositoryConfiguration.EventPrefetchCount,
|
||||
MaxConcurrentCalls = repositoryConfiguration.EventMaxConcurrentCalls
|
||||
},
|
||||
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
|
||||
)
|
||||
)
|
||||
);
|
||||
services.AddAzureServiceBusIntegration<SlackIntegrationConfigurationDetails, SlackListenerConfiguration>(slackConfiguration);
|
||||
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
|
||||
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
|
||||
services.AddAzureServiceBusIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
|
||||
services.AddAzureServiceBusIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
if (IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
RabbitMqEventListenerService<RepositoryListenerConfiguration>>(provider =>
|
||||
new RabbitMqEventListenerService<RepositoryListenerConfiguration>(
|
||||
handler: provider.GetRequiredService<EventRepositoryHandler>(),
|
||||
configuration: repositoryConfiguration,
|
||||
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
|
||||
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
|
||||
)
|
||||
)
|
||||
);
|
||||
services.AddRabbitMqIntegration<SlackIntegrationConfigurationDetails, SlackListenerConfiguration>(slackConfiguration);
|
||||
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
|
||||
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
|
||||
services.AddRabbitMqIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
|
||||
services.AddRabbitMqIntegration<TeamsIntegrationConfigurationDetails, TeamsListenerConfiguration>(teamsConfiguration);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Azure Service Bus-based event integration listeners for a specific integration type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TConfig">The integration configuration details type (e.g., SlackIntegrationConfigurationDetails).</typeparam>
|
||||
/// <typeparam name="TListenerConfig">The listener configuration type implementing IIntegrationListenerConfiguration.</typeparam>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="listenerConfiguration">The listener configuration containing routing keys and message processing settings.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method registers three key components:
|
||||
/// 1. EventIntegrationHandler - Keyed singleton for processing integration events
|
||||
/// 2. AzureServiceBusEventListenerService - Hosted service for listening to event messages from Azure Service Bus
|
||||
/// for this integration type
|
||||
/// 3. AzureServiceBusIntegrationListenerService - Hosted service for listening to integration messages from
|
||||
/// Azure Service Bus for this integration type
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The handler uses the listener configuration's routing key as its service key, allowing multiple
|
||||
/// handlers to be registered for different integration types.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Service Bus processor options (PrefetchCount and MaxConcurrentCalls) are configured from the listener
|
||||
/// configuration to optimize message throughput and concurrency.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IServiceCollection AddAzureServiceBusIntegration<TConfig, TListenerConfig>(this IServiceCollection services,
|
||||
TListenerConfig listenerConfiguration)
|
||||
where TConfig : class
|
||||
where TListenerConfig : IIntegrationListenerConfiguration
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) =>
|
||||
new EventIntegrationHandler<TConfig>(
|
||||
integrationType: listenerConfiguration.IntegrationType,
|
||||
eventIntegrationPublisher: provider.GetRequiredService<IEventIntegrationPublisher>(),
|
||||
integrationFilterService: provider.GetRequiredService<IIntegrationFilterService>(),
|
||||
cache: provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName),
|
||||
configurationRepository: provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
|
||||
groupRepository: provider.GetRequiredService<IGroupRepository>(),
|
||||
organizationRepository: provider.GetRequiredService<IOrganizationRepository>(),
|
||||
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(), logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>())
|
||||
);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
AzureServiceBusEventListenerService<TListenerConfig>>(provider =>
|
||||
new AzureServiceBusEventListenerService<TListenerConfig>(
|
||||
configuration: listenerConfiguration,
|
||||
handler: provider.GetRequiredKeyedService<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey),
|
||||
serviceBusService: provider.GetRequiredService<IAzureServiceBusService>(),
|
||||
serviceBusOptions: new ServiceBusProcessorOptions()
|
||||
{
|
||||
PrefetchCount = listenerConfiguration.EventPrefetchCount,
|
||||
MaxConcurrentCalls = listenerConfiguration.EventMaxConcurrentCalls
|
||||
},
|
||||
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
|
||||
)
|
||||
)
|
||||
);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
AzureServiceBusIntegrationListenerService<TListenerConfig>>(provider =>
|
||||
new AzureServiceBusIntegrationListenerService<TListenerConfig>(
|
||||
configuration: listenerConfiguration,
|
||||
handler: provider.GetRequiredService<IIntegrationHandler<TConfig>>(),
|
||||
serviceBusService: provider.GetRequiredService<IAzureServiceBusService>(),
|
||||
serviceBusOptions: new ServiceBusProcessorOptions()
|
||||
{
|
||||
PrefetchCount = listenerConfiguration.IntegrationPrefetchCount,
|
||||
MaxConcurrentCalls = listenerConfiguration.IntegrationMaxConcurrentCalls
|
||||
},
|
||||
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers RabbitMQ-based event integration listeners for a specific integration type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TConfig">The integration configuration details type (e.g., SlackIntegrationConfigurationDetails).</typeparam>
|
||||
/// <typeparam name="TListenerConfig">The listener configuration type implementing IIntegrationListenerConfiguration.</typeparam>
|
||||
/// <param name="services">The service collection to add services to.</param>
|
||||
/// <param name="listenerConfiguration">The listener configuration containing routing keys and message processing settings.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method registers three key components:
|
||||
/// 1. EventIntegrationHandler - Keyed singleton for processing integration events
|
||||
/// 2. RabbitMqEventListenerService - Hosted service for listening to event messages from RabbitMQ for
|
||||
/// this integration type
|
||||
/// 3. RabbitMqIntegrationListenerService - Hosted service for listening to integration messages from RabbitMQ for
|
||||
/// this integration type
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The handler uses the listener configuration's routing key as its service key, allowing multiple
|
||||
/// handlers to be registered for different integration types.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IServiceCollection AddRabbitMqIntegration<TConfig, TListenerConfig>(this IServiceCollection services,
|
||||
TListenerConfig listenerConfiguration)
|
||||
where TConfig : class
|
||||
where TListenerConfig : IIntegrationListenerConfiguration
|
||||
{
|
||||
services.TryAddKeyedSingleton<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) =>
|
||||
new EventIntegrationHandler<TConfig>(
|
||||
integrationType: listenerConfiguration.IntegrationType,
|
||||
eventIntegrationPublisher: provider.GetRequiredService<IEventIntegrationPublisher>(),
|
||||
integrationFilterService: provider.GetRequiredService<IIntegrationFilterService>(),
|
||||
cache: provider.GetRequiredKeyedService<IFusionCache>(EventIntegrationsCacheConstants.CacheName),
|
||||
configurationRepository: provider.GetRequiredService<IOrganizationIntegrationConfigurationRepository>(),
|
||||
groupRepository: provider.GetRequiredService<IGroupRepository>(),
|
||||
organizationRepository: provider.GetRequiredService<IOrganizationRepository>(),
|
||||
organizationUserRepository: provider.GetRequiredService<IOrganizationUserRepository>(), logger: provider.GetRequiredService<ILogger<EventIntegrationHandler<TConfig>>>())
|
||||
);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
RabbitMqEventListenerService<TListenerConfig>>(provider =>
|
||||
new RabbitMqEventListenerService<TListenerConfig>(
|
||||
handler: provider.GetRequiredKeyedService<IEventMessageHandler>(serviceKey: listenerConfiguration.RoutingKey),
|
||||
configuration: listenerConfiguration,
|
||||
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
|
||||
loggerFactory: provider.GetRequiredService<ILoggerFactory>()
|
||||
)
|
||||
)
|
||||
);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService,
|
||||
RabbitMqIntegrationListenerService<TListenerConfig>>(provider =>
|
||||
new RabbitMqIntegrationListenerService<TListenerConfig>(
|
||||
handler: provider.GetRequiredService<IIntegrationHandler<TConfig>>(),
|
||||
configuration: listenerConfiguration,
|
||||
rabbitMqService: provider.GetRequiredService<IRabbitMqService>(),
|
||||
loggerFactory: provider.GetRequiredService<ILoggerFactory>(),
|
||||
timeProvider: provider.GetRequiredService<TimeProvider>()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddScoped<ICreateOrganizationIntegrationCommand, CreateOrganizationIntegrationCommand>();
|
||||
services.TryAddScoped<IUpdateOrganizationIntegrationCommand, UpdateOrganizationIntegrationCommand>();
|
||||
services.TryAddScoped<IDeleteOrganizationIntegrationCommand, DeleteOrganizationIntegrationCommand>();
|
||||
services.TryAddScoped<IGetOrganizationIntegrationsQuery, GetOrganizationIntegrationsQuery>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if RabbitMQ is enabled for event integrations based on configuration settings.
|
||||
/// </summary>
|
||||
/// <param name="settings">The global settings containing RabbitMQ configuration.</param>
|
||||
/// <returns>True if all required RabbitMQ settings are present; otherwise, false.</returns>
|
||||
/// <remarks>
|
||||
/// Requires all the following settings to be configured:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>EventLogging.RabbitMq.HostName</description></item>
|
||||
/// <item><description>EventLogging.RabbitMq.Username</description></item>
|
||||
/// <item><description>EventLogging.RabbitMq.Password</description></item>
|
||||
/// <item><description>EventLogging.RabbitMq.EventExchangeName</description></item>
|
||||
/// <item><description>EventLogging.RabbitMq.IntegrationExchangeName</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static bool IsRabbitMqEnabled(GlobalSettings settings)
|
||||
{
|
||||
return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) &&
|
||||
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) &&
|
||||
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) &&
|
||||
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName) &&
|
||||
CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.IntegrationExchangeName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if Azure Service Bus is enabled for event integrations based on configuration settings.
|
||||
/// </summary>
|
||||
/// <param name="settings">The global settings containing Azure Service Bus configuration.</param>
|
||||
/// <returns>True if all required Azure Service Bus settings are present; otherwise, false.</returns>
|
||||
/// <remarks>
|
||||
/// Requires all of the following settings to be configured:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>EventLogging.AzureServiceBus.ConnectionString</description></item>
|
||||
/// <item><description>EventLogging.AzureServiceBus.EventTopicName</description></item>
|
||||
/// <item><description>EventLogging.AzureServiceBus.IntegrationTopicName</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static bool IsAzureServiceBusEnabled(GlobalSettings settings)
|
||||
{
|
||||
return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) &&
|
||||
CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName) &&
|
||||
CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.IntegrationTopicName);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Command implementation for creating organization integrations with cache invalidation support.
|
||||
/// </summary>
|
||||
public class CreateOrganizationIntegrationCommand(
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
|
||||
IFusionCache cache)
|
||||
: ICreateOrganizationIntegrationCommand
|
||||
{
|
||||
public async Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration)
|
||||
{
|
||||
var existingIntegrations = await integrationRepository
|
||||
.GetManyByOrganizationAsync(integration.OrganizationId);
|
||||
if (existingIntegrations.Any(i => i.Type == integration.Type))
|
||||
{
|
||||
throw new BadRequestException("An integration of this type already exists for this organization.");
|
||||
}
|
||||
|
||||
var created = await integrationRepository.CreateAsync(integration);
|
||||
await cache.RemoveByTagAsync(
|
||||
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId: integration.OrganizationId,
|
||||
integrationType: integration.Type
|
||||
));
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Command implementation for deleting organization integrations with cache invalidation support.
|
||||
/// </summary>
|
||||
public class DeleteOrganizationIntegrationCommand(
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache)
|
||||
: IDeleteOrganizationIntegrationCommand
|
||||
{
|
||||
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
|
||||
{
|
||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
||||
if (integration is null || integration.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await integrationRepository.DeleteAsync(integration);
|
||||
await cache.RemoveByTagAsync(
|
||||
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId: organizationId,
|
||||
integrationType: integration.Type
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Query implementation for retrieving organization integrations.
|
||||
/// </summary>
|
||||
public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository)
|
||||
: IGetOrganizationIntegrationsQuery
|
||||
{
|
||||
public async Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId)
|
||||
{
|
||||
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
|
||||
return integrations.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface for creating an OrganizationIntegration.
|
||||
/// </summary>
|
||||
public interface ICreateOrganizationIntegrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new organization integration.
|
||||
/// </summary>
|
||||
/// <param name="integration">The OrganizationIntegration to create.</param>
|
||||
/// <returns>The created OrganizationIntegration.</returns>
|
||||
/// <exception cref="Exceptions.BadRequestException">Thrown when an integration
|
||||
/// of the same type already exists for the organization.</exception>
|
||||
Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface for deleting organization integrations.
|
||||
/// </summary>
|
||||
public interface IDeleteOrganizationIntegrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes an organization integration.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization.</param>
|
||||
/// <param name="integrationId">The unique identifier of the integration to delete.</param>
|
||||
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
|
||||
/// or does not belong to the specified organization.</exception>
|
||||
Task DeleteAsync(Guid organizationId, Guid integrationId);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Query interface for retrieving organization integrations.
|
||||
/// </summary>
|
||||
public interface IGetOrganizationIntegrationsQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves all organization integrations for a specific organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization.</param>
|
||||
/// <returns>A list of organization integrations associated with the organization.</returns>
|
||||
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface for updating organization integrations.
|
||||
/// </summary>
|
||||
public interface IUpdateOrganizationIntegrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates an existing organization integration.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization.</param>
|
||||
/// <param name="integrationId">The unique identifier of the integration to update.</param>
|
||||
/// <param name="updatedIntegration">The updated organization integration data.</param>
|
||||
/// <returns>The updated organization integration.</returns>
|
||||
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist,
|
||||
/// does not belong to the specified organization, or the integration type does not match.</exception>
|
||||
Task<OrganizationIntegration> UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.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.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Command implementation for updating organization integrations with cache invalidation support.
|
||||
/// </summary>
|
||||
public class UpdateOrganizationIntegrationCommand(
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
|
||||
IFusionCache cache)
|
||||
: IUpdateOrganizationIntegrationCommand
|
||||
{
|
||||
public async Task<OrganizationIntegration> UpdateAsync(
|
||||
Guid organizationId,
|
||||
Guid integrationId,
|
||||
OrganizationIntegration updatedIntegration)
|
||||
{
|
||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
||||
if (integration is null ||
|
||||
integration.OrganizationId != organizationId ||
|
||||
integration.Type != updatedIntegration.Type)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
updatedIntegration.Id = integration.Id;
|
||||
updatedIntegration.OrganizationId = integration.OrganizationId;
|
||||
updatedIntegration.CreationDate = integration.CreationDate;
|
||||
await integrationRepository.ReplaceAsync(updatedIntegration);
|
||||
await cache.RemoveByTagAsync(
|
||||
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId: organizationId,
|
||||
integrationType: integration.Type
|
||||
));
|
||||
|
||||
return updatedIntegration;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegration(string ApiKey, Uri Uri);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri);
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class DatadogListenerConfiguration(GlobalSettings globalSettings)
|
||||
: ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType
|
||||
{
|
||||
get => IntegrationType.Datadog;
|
||||
}
|
||||
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.DatadogEventsQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationRetryQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationRetryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.DatadogEventSubscriptionName;
|
||||
}
|
||||
|
||||
public string IntegrationSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.DatadogIntegrationSubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null);
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class HecListenerConfiguration(GlobalSettings globalSettings)
|
||||
: ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType
|
||||
{
|
||||
get => IntegrationType.Hec;
|
||||
}
|
||||
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.HecEventsQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.HecIntegrationQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationRetryQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.HecIntegrationRetryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.HecEventSubscriptionName;
|
||||
}
|
||||
|
||||
public string IntegrationSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.HecIntegrationSubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public interface IEventListenerConfiguration
|
||||
{
|
||||
public string EventQueueName { get; }
|
||||
public string EventSubscriptionName { get; }
|
||||
public string EventTopicName { get; }
|
||||
public int EventPrefetchCount { get; }
|
||||
public int EventMaxConcurrentCalls { get; }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public interface IIntegrationListenerConfiguration : IEventListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType { get; }
|
||||
public string IntegrationQueueName { get; }
|
||||
public string IntegrationRetryQueueName { get; }
|
||||
public string IntegrationSubscriptionName { get; }
|
||||
public string IntegrationTopicName { get; }
|
||||
public int MaxRetries { get; }
|
||||
public int IntegrationPrefetchCount { get; }
|
||||
public int IntegrationMaxConcurrentCalls { get; }
|
||||
|
||||
public string RoutingKey
|
||||
{
|
||||
get => IntegrationType.ToRoutingKey();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public interface IIntegrationMessage
|
||||
{
|
||||
IntegrationType IntegrationType { get; }
|
||||
string MessageId { get; set; }
|
||||
string? OrganizationId { get; set; }
|
||||
int RetryCount { get; }
|
||||
DateTime? DelayUntilDate { get; }
|
||||
void ApplyRetry(DateTime? handlerDelayUntilDate);
|
||||
string ToJson();
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Categories of event integration failures used for classification and retry logic.
|
||||
/// </summary>
|
||||
public enum IntegrationFailureCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Service is temporarily unavailable (503, upstream outage, maintenance).
|
||||
/// </summary>
|
||||
ServiceUnavailable,
|
||||
|
||||
/// <summary>
|
||||
/// Authentication failed (401, 403, invalid_auth, token issues).
|
||||
/// </summary>
|
||||
AuthenticationFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration error (invalid config, channel_not_found, etc.).
|
||||
/// </summary>
|
||||
ConfigurationError,
|
||||
|
||||
/// <summary>
|
||||
/// Rate limited (429, rate_limited).
|
||||
/// </summary>
|
||||
RateLimited,
|
||||
|
||||
/// <summary>
|
||||
/// Transient error (timeouts, 500, network errors).
|
||||
/// </summary>
|
||||
TransientError,
|
||||
|
||||
/// <summary>
|
||||
/// Permanent failure unrelated to authentication/config (e.g., unrecoverable payload/format issue).
|
||||
/// </summary>
|
||||
PermanentFailure
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationFilterGroup
|
||||
{
|
||||
public bool AndOperator { get; init; } = true;
|
||||
public List<IntegrationFilterRule>? Rules { get; init; }
|
||||
public List<IntegrationFilterGroup>? Groups { get; init; }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public enum IntegrationFilterOperation
|
||||
{
|
||||
Equals = 0,
|
||||
NotEquals = 1,
|
||||
In = 2,
|
||||
NotIn = 3
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationFilterRule
|
||||
{
|
||||
public required string Property { get; set; }
|
||||
public required IntegrationFilterOperation Operation { get; set; }
|
||||
public required object? Value { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of an integration handler operation, including success status,
|
||||
/// failure categorization, and retry metadata. Use the <see cref="Succeed"/> factory method
|
||||
/// for successful operations or <see cref="Fail"/> for failures with automatic retry-ability
|
||||
/// determination based on the failure category.
|
||||
/// </summary>
|
||||
public class IntegrationHandlerResult
|
||||
{
|
||||
/// <summary>
|
||||
/// True if the integration send succeeded, false otherwise.
|
||||
/// </summary>
|
||||
public bool Success { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The integration message that was processed.
|
||||
/// </summary>
|
||||
public IIntegrationMessage Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional UTC date/time indicating when a failed operation should be retried.
|
||||
/// Will be used by the retry queue to delay re-sending the message.
|
||||
/// Usually set based on the Retry-After header from rate-limited responses.
|
||||
/// </summary>
|
||||
public DateTime? DelayUntilDate { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category of the failure. Null for successful results.
|
||||
/// </summary>
|
||||
public IntegrationFailureCategory? Category { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed failure reason or error message. Empty for successful results.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the operation is retryable.
|
||||
/// Computed from the failure category.
|
||||
/// </summary>
|
||||
public bool Retryable => Category switch
|
||||
{
|
||||
IntegrationFailureCategory.RateLimited => true,
|
||||
IntegrationFailureCategory.TransientError => true,
|
||||
IntegrationFailureCategory.ServiceUnavailable => true,
|
||||
IntegrationFailureCategory.AuthenticationFailed => false,
|
||||
IntegrationFailureCategory.ConfigurationError => false,
|
||||
IntegrationFailureCategory.PermanentFailure => false,
|
||||
null => false,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static IntegrationHandlerResult Succeed(IIntegrationMessage message)
|
||||
{
|
||||
return new IntegrationHandlerResult(success: true, message: message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with a failure category and reason.
|
||||
/// </summary>
|
||||
public static IntegrationHandlerResult Fail(
|
||||
IIntegrationMessage message,
|
||||
IntegrationFailureCategory category,
|
||||
string failureReason,
|
||||
DateTime? delayUntil = null)
|
||||
{
|
||||
return new IntegrationHandlerResult(success: false, message: message)
|
||||
{
|
||||
Category = category,
|
||||
FailureReason = failureReason,
|
||||
DelayUntilDate = delayUntil
|
||||
};
|
||||
}
|
||||
|
||||
private IntegrationHandlerResult(bool success, IIntegrationMessage message)
|
||||
{
|
||||
Success = success;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationMessage : IIntegrationMessage
|
||||
{
|
||||
public IntegrationType IntegrationType { get; set; }
|
||||
public required string MessageId { get; set; }
|
||||
public string? OrganizationId { get; set; }
|
||||
public required string RenderedTemplate { get; set; }
|
||||
public int RetryCount { get; set; } = 0;
|
||||
public DateTime? DelayUntilDate { get; set; }
|
||||
|
||||
public void ApplyRetry(DateTime? handlerDelayUntilDate)
|
||||
{
|
||||
RetryCount++;
|
||||
|
||||
var baseTime = handlerDelayUntilDate ?? DateTime.UtcNow;
|
||||
var backoffSeconds = Math.Pow(2, RetryCount);
|
||||
var jitterSeconds = Random.Shared.Next(0, 3);
|
||||
|
||||
DelayUntilDate = baseTime.AddSeconds(backoffSeconds + jitterSeconds);
|
||||
}
|
||||
|
||||
public virtual string ToJson()
|
||||
{
|
||||
return JsonSerializer.Serialize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public class IntegrationMessage<T> : IntegrationMessage
|
||||
{
|
||||
public required T Configuration { get; set; }
|
||||
|
||||
public override string ToJson()
|
||||
{
|
||||
return JsonSerializer.Serialize(this);
|
||||
}
|
||||
|
||||
public static IntegrationMessage<T>? FromJson(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<IntegrationMessage<T>>(json);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationOAuthState
|
||||
{
|
||||
private const int _orgHashLength = 12;
|
||||
private static readonly TimeSpan _maxAge = TimeSpan.FromMinutes(20);
|
||||
|
||||
public Guid IntegrationId { get; }
|
||||
private DateTimeOffset Issued { get; }
|
||||
private string OrganizationIdHash { get; }
|
||||
|
||||
private IntegrationOAuthState(Guid integrationId, string organizationIdHash, DateTimeOffset issued)
|
||||
{
|
||||
IntegrationId = integrationId;
|
||||
OrganizationIdHash = organizationIdHash;
|
||||
Issued = issued;
|
||||
}
|
||||
|
||||
public static IntegrationOAuthState FromIntegration(OrganizationIntegration integration, TimeProvider timeProvider)
|
||||
{
|
||||
var integrationId = integration.Id;
|
||||
var issuedUtc = timeProvider.GetUtcNow();
|
||||
var organizationIdHash = ComputeOrgHash(integration.OrganizationId, issuedUtc.ToUnixTimeSeconds());
|
||||
|
||||
return new IntegrationOAuthState(integrationId, organizationIdHash, issuedUtc);
|
||||
}
|
||||
|
||||
public static IntegrationOAuthState? FromString(string state, TimeProvider timeProvider)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(state)) return null;
|
||||
|
||||
var parts = state.Split('.');
|
||||
if (parts.Length != 3) return null;
|
||||
|
||||
// Verify timestamp
|
||||
if (!long.TryParse(parts[2], out var unixSeconds)) return null;
|
||||
|
||||
var issuedUtc = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var age = now - issuedUtc;
|
||||
|
||||
if (age > _maxAge) return null;
|
||||
|
||||
// Parse integration id and store org
|
||||
if (!Guid.TryParse(parts[0], out var integrationId)) return null;
|
||||
var organizationIdHash = parts[1];
|
||||
|
||||
return new IntegrationOAuthState(integrationId, organizationIdHash, issuedUtc);
|
||||
}
|
||||
|
||||
public bool ValidateOrg(Guid orgId)
|
||||
{
|
||||
var expected = ComputeOrgHash(orgId, Issued.ToUnixTimeSeconds());
|
||||
return expected == OrganizationIdHash;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{IntegrationId}.{OrganizationIdHash}.{Issued.ToUnixTimeSeconds()}";
|
||||
}
|
||||
|
||||
private static string ComputeOrgHash(Guid orgId, long timestamp)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{orgId:N}:{timestamp}"));
|
||||
return Convert.ToHexString(bytes)[.._orgHashLength];
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationTemplateContext(EventMessage eventMessage)
|
||||
{
|
||||
public EventMessage Event { get; } = eventMessage;
|
||||
|
||||
public string DomainName => Event.DomainName;
|
||||
public string IpAddress => Event.IpAddress;
|
||||
public DeviceType? DeviceType => Event.DeviceType;
|
||||
public Guid? ActingUserId => Event.ActingUserId;
|
||||
public Guid? OrganizationUserId => Event.OrganizationUserId;
|
||||
public DateTime Date => Event.Date;
|
||||
public EventType Type => Event.Type;
|
||||
public Guid? UserId => Event.UserId;
|
||||
public Guid? OrganizationId => Event.OrganizationId;
|
||||
public Guid? CipherId => Event.CipherId;
|
||||
public Guid? CollectionId => Event.CollectionId;
|
||||
public Guid? GroupId => Event.GroupId;
|
||||
public Guid? PolicyId => Event.PolicyId;
|
||||
public Guid? IdempotencyId => Event.IdempotencyId;
|
||||
public Guid? ProviderId => Event.ProviderId;
|
||||
public Guid? ProviderUserId => Event.ProviderUserId;
|
||||
public Guid? ProviderOrganizationId => Event.ProviderOrganizationId;
|
||||
public Guid? InstallationId => Event.InstallationId;
|
||||
public Guid? SecretId => Event.SecretId;
|
||||
public Guid? ProjectId => Event.ProjectId;
|
||||
public Guid? ServiceAccountId => Event.ServiceAccountId;
|
||||
public Guid? GrantedServiceAccountId => Event.GrantedServiceAccountId;
|
||||
|
||||
public string DateIso8601 => Date.ToString("o");
|
||||
public string EventMessage => JsonSerializer.Serialize(Event);
|
||||
|
||||
public OrganizationUserUserDetails? User { get; set; }
|
||||
public string? UserName => User?.Name;
|
||||
public string? UserEmail => User?.Email;
|
||||
public OrganizationUserType? UserType => User?.Type;
|
||||
|
||||
public OrganizationUserUserDetails? ActingUser { get; set; }
|
||||
public string? ActingUserName => ActingUser?.Name;
|
||||
public string? ActingUserEmail => ActingUser?.Email;
|
||||
public OrganizationUserType? ActingUserType => ActingUser?.Type;
|
||||
|
||||
public Group? Group { get; set; }
|
||||
public string? GroupName => Group?.Name;
|
||||
|
||||
public Organization? Organization { get; set; }
|
||||
public string? OrganizationName => Organization?.DisplayName();
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public abstract class ListenerConfiguration
|
||||
{
|
||||
protected GlobalSettings _globalSettings;
|
||||
|
||||
public ListenerConfiguration(GlobalSettings globalSettings)
|
||||
{
|
||||
_globalSettings = globalSettings;
|
||||
}
|
||||
|
||||
public int MaxRetries
|
||||
{
|
||||
get => _globalSettings.EventLogging.MaxRetries;
|
||||
}
|
||||
|
||||
public string EventTopicName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.EventTopicName;
|
||||
}
|
||||
|
||||
public string IntegrationTopicName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName;
|
||||
}
|
||||
|
||||
public int EventPrefetchCount
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.DefaultPrefetchCount;
|
||||
}
|
||||
|
||||
public int EventMaxConcurrentCalls
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.DefaultMaxConcurrentCalls;
|
||||
}
|
||||
|
||||
public int IntegrationPrefetchCount
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.DefaultPrefetchCount;
|
||||
}
|
||||
|
||||
public int IntegrationMaxConcurrentCalls
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.DefaultMaxConcurrentCalls;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class RepositoryListenerConfiguration(GlobalSettings globalSettings)
|
||||
: ListenerConfiguration(globalSettings), IEventListenerConfiguration
|
||||
{
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegration(string Token);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string ChannelId);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfigurationDetails(string ChannelId, string Token);
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class SlackListenerConfiguration(GlobalSettings globalSettings) :
|
||||
ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType
|
||||
{
|
||||
get => IntegrationType.Slack;
|
||||
}
|
||||
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.SlackEventsQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationRetryQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.SlackEventSubscriptionName;
|
||||
}
|
||||
|
||||
public string IntegrationSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.SlackIntegrationSubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record TeamsIntegration(
|
||||
string TenantId,
|
||||
IReadOnlyList<TeamInfo> Teams,
|
||||
string? ChannelId = null,
|
||||
Uri? ServiceUrl = null)
|
||||
{
|
||||
public bool IsCompleted => !string.IsNullOrEmpty(ChannelId) && ServiceUrl is not null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class TeamsListenerConfiguration(GlobalSettings globalSettings) :
|
||||
ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType
|
||||
{
|
||||
get => IntegrationType.Teams;
|
||||
}
|
||||
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.TeamsEventsQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationRetryQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationRetryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.TeamsEventSubscriptionName;
|
||||
}
|
||||
|
||||
public string IntegrationSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.TeamsIntegrationSubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null);
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class WebhookListenerConfiguration(GlobalSettings globalSettings)
|
||||
: ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
|
||||
{
|
||||
public IntegrationType IntegrationType
|
||||
{
|
||||
get => IntegrationType.Webhook;
|
||||
}
|
||||
|
||||
public string EventQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName;
|
||||
}
|
||||
|
||||
public string IntegrationRetryQueueName
|
||||
{
|
||||
get => _globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName;
|
||||
}
|
||||
|
||||
public string EventSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.WebhookEventSubscriptionName;
|
||||
}
|
||||
|
||||
public string IntegrationSubscriptionName
|
||||
{
|
||||
get => _globalSettings.EventLogging.AzureServiceBus.WebhookIntegrationSubscriptionName;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
|
||||
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; }
|
||||
public string? Configuration { get; set; }
|
||||
public string? Filters { get; set; }
|
||||
public string? IntegrationConfiguration { get; set; }
|
||||
public string? Template { get; set; }
|
||||
|
||||
public JsonObject MergedConfiguration
|
||||
{
|
||||
get
|
||||
{
|
||||
var integrationJson = IntegrationConfigurationJson;
|
||||
|
||||
foreach (var kvp in ConfigurationJson)
|
||||
{
|
||||
integrationJson[kvp.Key] = kvp.Value?.DeepClone();
|
||||
}
|
||||
|
||||
return integrationJson;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject ConfigurationJson
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var configuration = Configuration ?? string.Empty;
|
||||
return JsonNode.Parse(configuration) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject IntegrationConfigurationJson
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var integration = IntegrationConfiguration ?? string.Empty;
|
||||
return JsonNode.Parse(integration) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Slack;
|
||||
|
||||
public abstract class SlackApiResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
[JsonPropertyName("response_metadata")]
|
||||
public SlackResponseMetadata ResponseMetadata { get; set; } = new();
|
||||
public string Error { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackResponseMetadata
|
||||
{
|
||||
[JsonPropertyName("next_cursor")]
|
||||
public string NextCursor { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackChannelListResponse : SlackApiResponse
|
||||
{
|
||||
public List<SlackChannel> Channels { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SlackUserResponse : SlackApiResponse
|
||||
{
|
||||
public SlackUser User { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SlackOAuthResponse : SlackApiResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public SlackTeam Team { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SlackSendMessageResponse : SlackApiResponse
|
||||
{
|
||||
[JsonPropertyName("channel")]
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackTeam
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackChannel
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackUser
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SlackDmResponse : SlackApiResponse
|
||||
{
|
||||
public SlackChannel Channel { get; set; } = new();
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Teams;
|
||||
|
||||
/// <summary>Represents the response returned by the Microsoft OAuth 2.0 token endpoint.
|
||||
/// See <see href="https://learn.microsoft.com/graph/auth-v2-user">Microsoft identity platform and OAuth 2.0
|
||||
/// authorization code flow</see>.</summary>
|
||||
public class TeamsOAuthResponse
|
||||
{
|
||||
/// <summary>The access token issued by Microsoft, used to call the Microsoft Graph API.</summary>
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Represents the response from the <c>/me/joinedTeams</c> Microsoft Graph API call.
|
||||
/// See <see href="https://learn.microsoft.com/graph/api/user-list-joinedteams">List joined teams -
|
||||
/// Microsoft Graph v1.0</see>.</summary>
|
||||
public class JoinedTeamsResponse
|
||||
{
|
||||
/// <summary>The collection of teams that the user has joined.</summary>
|
||||
[JsonPropertyName("value")]
|
||||
public List<TeamInfo> Value { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Represents a Microsoft Teams team returned by the Graph API.
|
||||
/// See <see href="https://learn.microsoft.com/graph/api/resources/team">Team resource type -
|
||||
/// Microsoft Graph v1.0</see>.</summary>
|
||||
public class TeamInfo
|
||||
{
|
||||
/// <summary>The unique identifier of the team.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The name of the team.</summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The ID of the Microsoft Entra tenant for this team.</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Microsoft.Bot.Connector.Authentication;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Teams;
|
||||
|
||||
public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider
|
||||
{
|
||||
private const string _microsoftBotFrameworkIssuer = AuthenticationConstants.ToBotFromChannelTokenIssuer;
|
||||
|
||||
public Task<bool> IsValidAppIdAsync(string appId)
|
||||
{
|
||||
return Task.FromResult(appId == clientId);
|
||||
}
|
||||
|
||||
public Task<string?> GetAppPasswordAsync(string appId)
|
||||
{
|
||||
return Task.FromResult(appId == clientId ? clientSecret : null);
|
||||
}
|
||||
|
||||
public Task<bool> IsAuthenticationDisabledAsync()
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateIssuerAsync(string issuer)
|
||||
{
|
||||
return Task.FromResult(issuer == _microsoftBotFrameworkIssuer);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve the list of available configuration details for a specific event for the organization and
|
||||
/// integration type.<br/>
|
||||
/// <br/>
|
||||
/// <b>Note:</b> This returns all configurations that match the event type explicitly <b>and</b>
|
||||
/// all the configurations that have a null event type - null event type is considered a
|
||||
/// wildcard that matches all events.
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="eventType">The specific event type</param>
|
||||
/// <param name="organizationId">The id of the organization</param>
|
||||
/// <param name="integrationType">The integration type</param>
|
||||
/// <returns>A List of <see cref="OrganizationIntegrationConfigurationDetails"/> that match</returns>
|
||||
Task<List<OrganizationIntegrationConfigurationDetails>> GetManyByEventTypeOrganizationIdIntegrationType(
|
||||
EventType eventType,
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType);
|
||||
|
||||
Task<List<OrganizationIntegrationConfigurationDetails>> GetAllConfigurationDetailsAsync();
|
||||
|
||||
Task<List<OrganizationIntegrationConfiguration>> GetManyByIntegrationAsync(Guid organizationIntegrationId);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
|
||||
{
|
||||
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
|
||||
|
||||
Task<OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public abstract class EventLoggingListenerService : BackgroundService
|
||||
{
|
||||
protected readonly IEventMessageHandler _handler;
|
||||
protected ILogger _logger;
|
||||
|
||||
protected EventLoggingListenerService(IEventMessageHandler handler, ILogger logger)
|
||||
{
|
||||
_handler = handler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
internal async Task ProcessReceivedMessageAsync(string body, string? messageId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(body);
|
||||
var root = jsonDocument.RootElement;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var eventMessages = root.Deserialize<IEnumerable<EventMessage>>();
|
||||
await _handler.HandleManyEventsAsync(eventMessages ?? throw new JsonException("Deserialize returned null"));
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var eventMessage = root.Deserialize<EventMessage>();
|
||||
await _handler.HandleEventAsync(eventMessage ?? throw new JsonException("Deserialize returned null"));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(messageId))
|
||||
{
|
||||
_logger.LogError("An error occurred while processing message: {MessageId} - Invalid JSON", messageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("An Invalid JSON error occurred while processing a message with an empty message id");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(messageId))
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occurred while processing message: {MessageId} - Invalid JSON",
|
||||
messageId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An Invalid JSON error occurred while processing a message with an empty message id"
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(messageId))
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occurred while processing message: {MessageId}",
|
||||
messageId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occurred while processing a message with an empty message id"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IAzureServiceBusService : IEventIntegrationPublisher, IAsyncDisposable
|
||||
{
|
||||
ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options);
|
||||
Task PublishToRetryAsync(IIntegrationMessage message);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IEventIntegrationPublisher : IAsyncDisposable
|
||||
{
|
||||
Task PublishAsync(IIntegrationMessage message);
|
||||
Task PublishEventAsync(string body, string? organizationId);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IEventMessageHandler
|
||||
{
|
||||
Task HandleEventAsync(EventMessage eventMessage);
|
||||
|
||||
Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IIntegrationConfigurationDetailsCache
|
||||
{
|
||||
List<OrganizationIntegrationConfigurationDetails> GetConfigurationDetails(
|
||||
Guid organizationId,
|
||||
IntegrationType integrationType,
|
||||
EventType eventType);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IIntegrationFilterService
|
||||
{
|
||||
bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IIntegrationHandler
|
||||
{
|
||||
Task<IntegrationHandlerResult> HandleAsync(string json);
|
||||
}
|
||||
|
||||
public interface IIntegrationHandler<T> : IIntegrationHandler
|
||||
{
|
||||
Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<T> message);
|
||||
}
|
||||
|
||||
public abstract class IntegrationHandlerBase<T> : IIntegrationHandler<T>
|
||||
{
|
||||
public async Task<IntegrationHandlerResult> HandleAsync(string json)
|
||||
{
|
||||
var message = IntegrationMessage<T>.FromJson(json);
|
||||
return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON"));
|
||||
}
|
||||
|
||||
public abstract Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<T> message);
|
||||
|
||||
protected IntegrationHandlerResult ResultFromHttpResponse(
|
||||
HttpResponseMessage response,
|
||||
IntegrationMessage<T> message,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return IntegrationHandlerResult.Succeed(message);
|
||||
}
|
||||
|
||||
var category = ClassifyHttpStatusCode(response.StatusCode);
|
||||
var failureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}";
|
||||
|
||||
if (category is not (IntegrationFailureCategory.RateLimited
|
||||
or IntegrationFailureCategory.TransientError
|
||||
or IntegrationFailureCategory.ServiceUnavailable) ||
|
||||
!response.Headers.TryGetValues("Retry-After", out var values)
|
||||
)
|
||||
{
|
||||
return IntegrationHandlerResult.Fail(message: message, category: category, failureReason: failureReason);
|
||||
}
|
||||
|
||||
// Handle Retry-After header for rate-limited and retryable errors
|
||||
DateTime? delayUntil = null;
|
||||
var value = values.FirstOrDefault();
|
||||
if (int.TryParse(value, out var seconds))
|
||||
{
|
||||
// Retry-after was specified in seconds
|
||||
delayUntil = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
|
||||
}
|
||||
else if (DateTimeOffset.TryParseExact(value,
|
||||
"r", // "r" is the round-trip format: RFC1123
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var retryDate))
|
||||
{
|
||||
// Retry-after was specified as a date
|
||||
delayUntil = retryDate.UtcDateTime;
|
||||
}
|
||||
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
category,
|
||||
failureReason,
|
||||
delayUntil
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies an <see cref="HttpStatusCode"/> as an <see cref="IntegrationFailureCategory"/> to drive
|
||||
/// retry behavior and operator-facing failure reporting.
|
||||
/// </summary>
|
||||
/// <param name="statusCode">The HTTP status code.</param>
|
||||
/// <returns>The corresponding <see cref="IntegrationFailureCategory"/>.</returns>
|
||||
protected static IntegrationFailureCategory ClassifyHttpStatusCode(HttpStatusCode statusCode)
|
||||
{
|
||||
var explicitCategory = statusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => IntegrationFailureCategory.AuthenticationFailed,
|
||||
HttpStatusCode.Forbidden => IntegrationFailureCategory.AuthenticationFailed,
|
||||
HttpStatusCode.NotFound => IntegrationFailureCategory.ConfigurationError,
|
||||
HttpStatusCode.Gone => IntegrationFailureCategory.ConfigurationError,
|
||||
HttpStatusCode.MovedPermanently => IntegrationFailureCategory.ConfigurationError,
|
||||
HttpStatusCode.TemporaryRedirect => IntegrationFailureCategory.ConfigurationError,
|
||||
HttpStatusCode.PermanentRedirect => IntegrationFailureCategory.ConfigurationError,
|
||||
HttpStatusCode.TooManyRequests => IntegrationFailureCategory.RateLimited,
|
||||
HttpStatusCode.RequestTimeout => IntegrationFailureCategory.TransientError,
|
||||
HttpStatusCode.InternalServerError => IntegrationFailureCategory.TransientError,
|
||||
HttpStatusCode.BadGateway => IntegrationFailureCategory.TransientError,
|
||||
HttpStatusCode.GatewayTimeout => IntegrationFailureCategory.TransientError,
|
||||
HttpStatusCode.ServiceUnavailable => IntegrationFailureCategory.ServiceUnavailable,
|
||||
HttpStatusCode.NotImplemented => IntegrationFailureCategory.PermanentFailure,
|
||||
_ => (IntegrationFailureCategory?)null
|
||||
};
|
||||
|
||||
if (explicitCategory is not null)
|
||||
{
|
||||
return explicitCategory.Value;
|
||||
}
|
||||
|
||||
return (int)statusCode switch
|
||||
{
|
||||
>= 300 and <= 399 => IntegrationFailureCategory.ConfigurationError,
|
||||
>= 400 and <= 499 => IntegrationFailureCategory.ConfigurationError,
|
||||
>= 500 and <= 599 => IntegrationFailureCategory.ServiceUnavailable,
|
||||
_ => IntegrationFailureCategory.ServiceUnavailable
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IRabbitMqService : IEventIntegrationPublisher
|
||||
{
|
||||
Task<IChannel> CreateChannelAsync(CancellationToken cancellationToken = default);
|
||||
Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default);
|
||||
Task CreateIntegrationQueuesAsync(
|
||||
string queueName,
|
||||
string retryQueueName,
|
||||
string routingKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken);
|
||||
Task PublishToDeadLetterAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken);
|
||||
Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Bit.Core.Models.Slack;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
|
||||
/// and sending messages.</summary>
|
||||
public interface ISlackService
|
||||
{
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up channels for a user.</remarks>
|
||||
/// <summary>Retrieves the ID of a Slack channel by name.
|
||||
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="channelName">The name of the channel to look up.</param>
|
||||
/// <returns>The channel ID if found; otherwise, an empty string.</returns>
|
||||
Task<string> GetChannelIdAsync(string token, string channelName);
|
||||
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up channels for a user.</remarks>
|
||||
/// <summary>Retrieves the IDs of multiple Slack channels by name.
|
||||
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="channelNames">A list of channel names to look up.</param>
|
||||
/// <returns>A list of matching channel IDs. Channels that cannot be found are omitted.</returns>
|
||||
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
|
||||
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up a user by their email address.</remarks>
|
||||
/// <summary>Retrieves the DM channel ID for a Slack user by email.
|
||||
/// See <see href="https://api.slack.com/methods/users.lookupByEmail">users.lookupByEmail API</see> and
|
||||
/// <see href="https://api.slack.com/methods/conversations.open">conversations.open API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="email">The email address of the user to open a DM with.</param>
|
||||
/// <returns>The DM channel ID if successful; otherwise, an empty string.</returns>
|
||||
Task<string> GetDmChannelByEmailAsync(string token, string email);
|
||||
|
||||
/// <summary>Builds the Slack OAuth 2.0 authorization URL for the app.
|
||||
/// See <see href="https://api.slack.com/authentication/oauth-v2">Slack OAuth v2 documentation</see>.</summary>
|
||||
/// <param name="callbackUrl">The absolute redirect URI that Slack will call after user authorization.
|
||||
/// Must match the URI registered with the app configuration.</param>
|
||||
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
|
||||
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
|
||||
string GetRedirectUrl(string callbackUrl, string state);
|
||||
|
||||
/// <summary>Exchanges a Slack OAuth code for an access token.
|
||||
/// See <see href="https://api.slack.com/methods/oauth.v2.access">oauth.v2.access API</see>.</summary>
|
||||
/// <param name="code">The authorization code returned by Slack via the callback URL after user authorization.</param>
|
||||
/// <param name="redirectUrl">The redirect URI that was used in the authorization request.</param>
|
||||
/// <returns>A valid Slack access token if successful; otherwise, an empty string.</returns>
|
||||
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
|
||||
|
||||
/// <summary>Sends a message to a Slack channel by ID.
|
||||
/// See <see href="https://api.slack.com/methods/chat.postMessage">chat.postMessage API</see>.</summary>
|
||||
/// <remarks>This is used primarily by the <see cref="SlackIntegrationHandler"/> to send events to the
|
||||
/// Slack channel.</remarks>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="message">The message text to send.</param>
|
||||
/// <param name="channelId">The channel ID to send the message to.</param>
|
||||
/// <returns>The response from Slack after sending the message.</returns>
|
||||
Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides functionality relating to the Microsoft Teams integration including OAuth,
|
||||
/// team discovery and sending a message to a channel in Teams.
|
||||
/// </summary>
|
||||
public interface ITeamsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate the Microsoft Teams OAuth 2.0 authorization URL used to begin the sign-in flow.
|
||||
/// </summary>
|
||||
/// <param name="callbackUrl">The absolute redirect URI that Microsoft will call after user authorization.
|
||||
/// Must match the URI registered with the app configuration.</param>
|
||||
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
|
||||
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
|
||||
string GetRedirectUrl(string callbackUrl, string state);
|
||||
|
||||
/// <summary>
|
||||
/// Exchange the OAuth code for a Microsoft Graph API access token.
|
||||
/// </summary>
|
||||
/// <param name="code">The code returned from Microsoft via the OAuth callback Url.</param>
|
||||
/// <param name="redirectUrl">The same redirect URI that was passed to the authorization request.</param>
|
||||
/// <returns>A valid Microsoft Graph access token if the exchange succeeds; otherwise, an empty string.</returns>
|
||||
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Get the Teams to which the authenticated user belongs via Microsoft Graph API.
|
||||
/// </summary>
|
||||
/// <param name="accessToken">A valid Microsoft Graph access token for the user (obtained via OAuth).</param>
|
||||
/// <returns>A read-only list of <see cref="TeamInfo"/> objects representing the user’s joined teams.
|
||||
/// Returns an empty list if the request fails or if the token is invalid.</returns>
|
||||
Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken);
|
||||
|
||||
/// <summary>
|
||||
/// Send a message to a specific channel in Teams.
|
||||
/// </summary>
|
||||
/// <remarks>This is used primarily by the <see cref="TeamsIntegrationHandler"/> to send events to the
|
||||
/// Teams channel.</remarks>
|
||||
/// <param name="serviceUri">The service URI associated with the Microsoft Bot Framework connector for the target
|
||||
/// team. Obtained via the bot framework callback.</param>
|
||||
/// <param name="channelId"> The conversation or channel ID where the message should be delivered. Obtained via
|
||||
/// the bot framework callback.</param>
|
||||
/// <param name="message">The message text to post to the channel.</param>
|
||||
/// <returns>A task that completes when the message has been sent. Errors during message delivery are surfaced
|
||||
/// as exceptions from the underlying connector client.</returns>
|
||||
Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AzureTableStorageEventHandler(
|
||||
[FromKeyedServices("persistent")] IEventWriteService eventWriteService)
|
||||
: IEventMessageHandler
|
||||
{
|
||||
public Task HandleEventAsync(EventMessage eventMessage)
|
||||
{
|
||||
return eventWriteService.CreateManyAsync(EventTableEntity.IndexEvent(eventMessage));
|
||||
}
|
||||
|
||||
public Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
return eventWriteService.CreateManyAsync(eventMessages.SelectMany(EventTableEntity.IndexEvent));
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Text;
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AzureServiceBusEventListenerService<TConfiguration> : EventLoggingListenerService
|
||||
where TConfiguration : IEventListenerConfiguration
|
||||
{
|
||||
private readonly ServiceBusProcessor _processor;
|
||||
|
||||
public AzureServiceBusEventListenerService(
|
||||
TConfiguration configuration,
|
||||
IEventMessageHandler handler,
|
||||
IAzureServiceBusService serviceBusService,
|
||||
ServiceBusProcessorOptions serviceBusOptions,
|
||||
ILoggerFactory loggerFactory)
|
||||
: base(handler, CreateLogger(loggerFactory, configuration))
|
||||
{
|
||||
_processor = serviceBusService.CreateProcessor(
|
||||
topicName: configuration.EventTopicName,
|
||||
subscriptionName: configuration.EventSubscriptionName,
|
||||
options: serviceBusOptions);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_processor.ProcessMessageAsync += ProcessReceivedMessageAsync;
|
||||
_processor.ProcessErrorAsync += ProcessErrorAsync;
|
||||
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await _processor.DisposeAsync();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration)
|
||||
{
|
||||
return loggerFactory.CreateLogger(
|
||||
categoryName: $"Bit.Core.Services.AzureServiceBusEventListenerService.{configuration.EventSubscriptionName}");
|
||||
}
|
||||
|
||||
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
|
||||
{
|
||||
_logger.LogError(
|
||||
args.Exception,
|
||||
"An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}",
|
||||
args.EntityPath,
|
||||
args.ErrorSource
|
||||
);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ProcessReceivedMessageAsync(ProcessMessageEventArgs args)
|
||||
{
|
||||
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AzureServiceBusIntegrationListenerService<TConfiguration> : BackgroundService
|
||||
where TConfiguration : IIntegrationListenerConfiguration
|
||||
{
|
||||
private readonly int _maxRetries;
|
||||
private readonly IAzureServiceBusService _serviceBusService;
|
||||
private readonly IIntegrationHandler _handler;
|
||||
private readonly ServiceBusProcessor _processor;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public AzureServiceBusIntegrationListenerService(
|
||||
TConfiguration configuration,
|
||||
IIntegrationHandler handler,
|
||||
IAzureServiceBusService serviceBusService,
|
||||
ServiceBusProcessorOptions serviceBusOptions,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_handler = handler;
|
||||
_logger = loggerFactory.CreateLogger(
|
||||
categoryName: $"Bit.Core.Services.AzureServiceBusIntegrationListenerService.{configuration.IntegrationSubscriptionName}");
|
||||
_maxRetries = configuration.MaxRetries;
|
||||
_serviceBusService = serviceBusService;
|
||||
|
||||
_processor = _serviceBusService.CreateProcessor(
|
||||
topicName: configuration.IntegrationTopicName,
|
||||
subscriptionName: configuration.IntegrationSubscriptionName,
|
||||
options: serviceBusOptions);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_processor.ProcessMessageAsync += HandleMessageAsync;
|
||||
_processor.ProcessErrorAsync += ProcessErrorAsync;
|
||||
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await _processor.DisposeAsync();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
|
||||
{
|
||||
_logger.LogError(
|
||||
args.Exception,
|
||||
"An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}",
|
||||
args.EntityPath,
|
||||
args.ErrorSource
|
||||
);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal async Task<bool> HandleMessageAsync(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _handler.HandleAsync(body);
|
||||
var message = result.Message;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Successful integration. Return true to indicate the message has been handled
|
||||
return true;
|
||||
}
|
||||
|
||||
message.ApplyRetry(result.DelayUntilDate);
|
||||
|
||||
if (result.Retryable && message.RetryCount < _maxRetries)
|
||||
{
|
||||
// Publish message to the retry queue. It will be re-published for retry after a delay
|
||||
// Return true to indicate the message has been handled
|
||||
await _serviceBusService.PublishToRetryAsync(message);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-recoverable failure or exceeded the max number of retries
|
||||
// Return false to indicate this message should be dead-lettered
|
||||
_logger.LogWarning(
|
||||
"Integration failure - non-recoverable error or max retries exceeded. " +
|
||||
"MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " +
|
||||
"FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}",
|
||||
message.MessageId,
|
||||
message.IntegrationType,
|
||||
message.OrganizationId,
|
||||
result.Category,
|
||||
result.FailureReason,
|
||||
message.RetryCount,
|
||||
_maxRetries);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unknown exception - log error, return true so the message will be acknowledged and not resent
|
||||
_logger.LogError(ex, "Unhandled error processing ASB message");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(ProcessMessageEventArgs args)
|
||||
{
|
||||
var json = args.Message.Body.ToString();
|
||||
if (await HandleMessageAsync(json))
|
||||
{
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
await args.DeadLetterMessageAsync(args.Message, "Retry limit exceeded or non-retryable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AzureServiceBusService : IAzureServiceBusService
|
||||
{
|
||||
private readonly ServiceBusClient _client;
|
||||
private readonly ServiceBusSender _eventSender;
|
||||
private readonly ServiceBusSender _integrationSender;
|
||||
|
||||
public AzureServiceBusService(GlobalSettings globalSettings)
|
||||
{
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString);
|
||||
_eventSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.EventTopicName);
|
||||
_integrationSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName);
|
||||
}
|
||||
|
||||
public ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options)
|
||||
{
|
||||
return _client.CreateProcessor(topicName, subscriptionName, options);
|
||||
}
|
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message)
|
||||
{
|
||||
var json = message.ToJson();
|
||||
|
||||
var serviceBusMessage = new ServiceBusMessage(json)
|
||||
{
|
||||
Subject = message.IntegrationType.ToRoutingKey(),
|
||||
MessageId = message.MessageId,
|
||||
PartitionKey = message.OrganizationId
|
||||
};
|
||||
|
||||
await _integrationSender.SendMessageAsync(serviceBusMessage);
|
||||
}
|
||||
|
||||
public async Task PublishToRetryAsync(IIntegrationMessage message)
|
||||
{
|
||||
var json = message.ToJson();
|
||||
|
||||
var serviceBusMessage = new ServiceBusMessage(json)
|
||||
{
|
||||
Subject = message.IntegrationType.ToRoutingKey(),
|
||||
ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow,
|
||||
MessageId = message.MessageId,
|
||||
PartitionKey = message.OrganizationId
|
||||
};
|
||||
|
||||
await _integrationSender.SendMessageAsync(serviceBusMessage);
|
||||
}
|
||||
|
||||
public async Task PublishEventAsync(string body, string? organizationId)
|
||||
{
|
||||
var message = new ServiceBusMessage(body)
|
||||
{
|
||||
ContentType = "application/json",
|
||||
MessageId = Guid.NewGuid().ToString(),
|
||||
PartitionKey = organizationId
|
||||
};
|
||||
|
||||
await _eventSender.SendMessageAsync(message);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _eventSender.DisposeAsync();
|
||||
await _integrationSender.DisposeAsync();
|
||||
await _client.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class DatadogIntegrationHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TimeProvider timeProvider)
|
||||
: IntegrationHandlerBase<DatadogIntegrationConfigurationDetails>
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
public const string HttpClientName = "DatadogIntegrationHandlerHttpClient";
|
||||
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<DatadogIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri);
|
||||
request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
|
||||
request.Headers.Add("DD-API-KEY", message.Configuration.ApiKey);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
return ResultFromHttpResponse(response, message, timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class EventIntegrationHandler<T>(
|
||||
IntegrationType integrationType,
|
||||
IEventIntegrationPublisher eventIntegrationPublisher,
|
||||
IIntegrationFilterService integrationFilterService,
|
||||
IFusionCache cache,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository,
|
||||
IGroupRepository groupRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILogger<EventIntegrationHandler<T>> logger)
|
||||
: IEventMessageHandler
|
||||
{
|
||||
public async Task HandleEventAsync(EventMessage eventMessage)
|
||||
{
|
||||
foreach (var configuration in await GetConfigurationDetailsListAsync(eventMessage))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (configuration.Filters is string filterJson)
|
||||
{
|
||||
// Evaluate filters - if false, then discard and do not process
|
||||
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(filterJson)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize Filters to FilterGroup");
|
||||
if (!integrationFilterService.EvaluateFilterGroup(filters, eventMessage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Valid filter - assemble message and publish to Integration topic/exchange
|
||||
var template = configuration.Template ?? string.Empty;
|
||||
var context = await BuildContextAsync(eventMessage, template);
|
||||
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
|
||||
var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid();
|
||||
var config = configuration.MergedConfiguration.Deserialize<T>()
|
||||
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name} - bad Configuration");
|
||||
|
||||
var message = new IntegrationMessage<T>
|
||||
{
|
||||
IntegrationType = integrationType,
|
||||
MessageId = messageId.ToString(),
|
||||
OrganizationId = eventMessage.OrganizationId?.ToString(),
|
||||
Configuration = config,
|
||||
RenderedTemplate = renderedTemplate,
|
||||
RetryCount = 0,
|
||||
DelayUntilDate = null
|
||||
};
|
||||
|
||||
await eventIntegrationPublisher.PublishAsync(message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to publish Integration Message for {Type}, check Id {RecordId} for error in Configuration or Filters",
|
||||
typeof(T).Name,
|
||||
configuration.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
await HandleEventAsync(eventMessage);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
|
||||
{
|
||||
// Note: All of these cache calls use the default options, including TTL of 30 minutes
|
||||
|
||||
var context = new IntegrationTemplateContext(eventMessage);
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresGroup(template) && eventMessage.GroupId.HasValue)
|
||||
{
|
||||
context.Group = await cache.GetOrSetAsync<Group?>(
|
||||
key: EventIntegrationsCacheConstants.BuildCacheKeyForGroup(eventMessage.GroupId.Value),
|
||||
factory: async _ => await groupRepository.GetByIdAsync(eventMessage.GroupId.Value)
|
||||
);
|
||||
}
|
||||
|
||||
if (eventMessage.OrganizationId is not Guid organizationId)
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue)
|
||||
{
|
||||
context.User = await GetUserFromCacheAsync(organizationId, eventMessage.UserId.Value);
|
||||
}
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue)
|
||||
{
|
||||
context.ActingUser = await GetUserFromCacheAsync(organizationId, eventMessage.ActingUserId.Value);
|
||||
}
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template))
|
||||
{
|
||||
context.Organization = await cache.GetOrSetAsync<Organization?>(
|
||||
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(organizationId),
|
||||
factory: async _ => await organizationRepository.GetByIdAsync(organizationId)
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsListAsync(EventMessage eventMessage)
|
||||
{
|
||||
if (eventMessage.OrganizationId is not Guid organizationId)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<OrganizationIntegrationConfigurationDetails> configurations = [];
|
||||
|
||||
var integrationTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId,
|
||||
integrationType
|
||||
);
|
||||
|
||||
configurations.AddRange(await cache.GetOrSetAsync<List<OrganizationIntegrationConfigurationDetails>>(
|
||||
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
|
||||
organizationId: organizationId,
|
||||
integrationType: integrationType,
|
||||
eventType: eventMessage.Type),
|
||||
factory: async _ => await configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType(
|
||||
eventType: eventMessage.Type,
|
||||
organizationId: organizationId,
|
||||
integrationType: integrationType),
|
||||
options: new FusionCacheEntryOptions(
|
||||
duration: EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails),
|
||||
tags: [integrationTag]
|
||||
));
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
private async Task<OrganizationUserUserDetails?> GetUserFromCacheAsync(Guid organizationId, Guid userId) =>
|
||||
await cache.GetOrSetAsync<OrganizationUserUserDetails?>(
|
||||
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId),
|
||||
factory: async _ => await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync(
|
||||
organizationId: organizationId,
|
||||
userId: userId
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class EventRepositoryHandler(
|
||||
[FromKeyedServices("persistent")] IEventWriteService eventWriteService)
|
||||
: IEventMessageHandler
|
||||
{
|
||||
public Task HandleEventAsync(EventMessage eventMessage)
|
||||
{
|
||||
return eventWriteService.CreateAsync(eventMessage);
|
||||
}
|
||||
|
||||
public Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
return eventWriteService.CreateManyAsync(eventMessages);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System.Linq.Expressions;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public delegate bool IntegrationFilter(EventMessage message, object? value);
|
||||
|
||||
public static class IntegrationFilterFactory
|
||||
{
|
||||
public static IntegrationFilter BuildEqualityFilter<T>(string propertyName)
|
||||
{
|
||||
var param = Expression.Parameter(typeof(EventMessage), "m");
|
||||
var valueParam = Expression.Parameter(typeof(object), "val");
|
||||
|
||||
var property = Expression.PropertyOrField(param, propertyName);
|
||||
var typedVal = Expression.Convert(valueParam, typeof(T));
|
||||
var body = Expression.Equal(property, typedVal);
|
||||
|
||||
var lambda = Expression.Lambda<Func<EventMessage, object?, bool>>(body, param, valueParam);
|
||||
return new IntegrationFilter(lambda.Compile());
|
||||
}
|
||||
|
||||
public static IntegrationFilter BuildInFilter<T>(string propertyName)
|
||||
{
|
||||
var param = Expression.Parameter(typeof(EventMessage), "m");
|
||||
var valueParam = Expression.Parameter(typeof(object), "val");
|
||||
|
||||
var property = Expression.PropertyOrField(param, propertyName);
|
||||
|
||||
var method = typeof(Enumerable)
|
||||
.GetMethods()
|
||||
.FirstOrDefault(m =>
|
||||
m.Name == "Contains"
|
||||
&& m.GetParameters().Length == 2)
|
||||
?.MakeGenericMethod(typeof(T));
|
||||
if (method is null)
|
||||
{
|
||||
throw new InvalidOperationException("Could not find Contains method.");
|
||||
}
|
||||
|
||||
var listType = typeof(IEnumerable<T>);
|
||||
var castedList = Expression.Convert(valueParam, listType);
|
||||
|
||||
var containsCall = Expression.Call(method, castedList, property);
|
||||
|
||||
var lambda = Expression.Lambda<Func<EventMessage, object?, bool>>(containsCall, param, valueParam);
|
||||
return new IntegrationFilter(lambda.Compile());
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class IntegrationFilterService : IIntegrationFilterService
|
||||
{
|
||||
private readonly Dictionary<string, IntegrationFilter> _equalsFilters = new();
|
||||
private readonly Dictionary<string, IntegrationFilter> _inFilters = new();
|
||||
private static readonly string[] _filterableProperties = new[]
|
||||
{
|
||||
"UserId",
|
||||
"InstallationId",
|
||||
"ProviderId",
|
||||
"CipherId",
|
||||
"CollectionId",
|
||||
"GroupId",
|
||||
"PolicyId",
|
||||
"OrganizationUserId",
|
||||
"ProviderUserId",
|
||||
"ProviderOrganizationId",
|
||||
"ActingUserId",
|
||||
"SecretId",
|
||||
"ServiceAccountId"
|
||||
};
|
||||
|
||||
public IntegrationFilterService()
|
||||
{
|
||||
BuildFilters();
|
||||
}
|
||||
|
||||
public bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message)
|
||||
{
|
||||
var ruleResults = group.Rules?.Select(
|
||||
rule => EvaluateRule(rule, message)
|
||||
) ?? Enumerable.Empty<bool>();
|
||||
var groupResults = group.Groups?.Select(
|
||||
innerGroup => EvaluateFilterGroup(innerGroup, message)
|
||||
) ?? Enumerable.Empty<bool>();
|
||||
|
||||
var results = ruleResults.Concat(groupResults);
|
||||
return group.AndOperator ? results.All(r => r) : results.Any(r => r);
|
||||
}
|
||||
|
||||
private bool EvaluateRule(IntegrationFilterRule rule, EventMessage message)
|
||||
{
|
||||
var key = rule.Property;
|
||||
return rule.Operation switch
|
||||
{
|
||||
IntegrationFilterOperation.Equals => _equalsFilters.TryGetValue(key, out var equals) &&
|
||||
equals(message, ToGuid(rule.Value)),
|
||||
IntegrationFilterOperation.NotEquals => !(_equalsFilters.TryGetValue(key, out var equals) &&
|
||||
equals(message, ToGuid(rule.Value))),
|
||||
IntegrationFilterOperation.In => _inFilters.TryGetValue(key, out var inList) &&
|
||||
inList(message, ToGuidList(rule.Value)),
|
||||
IntegrationFilterOperation.NotIn => !(_inFilters.TryGetValue(key, out var inList) &&
|
||||
inList(message, ToGuidList(rule.Value))),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildFilters()
|
||||
{
|
||||
foreach (var property in _filterableProperties)
|
||||
{
|
||||
_equalsFilters[property] = IntegrationFilterFactory.BuildEqualityFilter<Guid?>(property);
|
||||
_inFilters[property] = IntegrationFilterFactory.BuildInFilter<Guid?>(property);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid? ToGuid(object? value)
|
||||
{
|
||||
if (value is Guid guid)
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
if (value is string stringValue)
|
||||
{
|
||||
return Guid.Parse(stringValue);
|
||||
}
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
return jsonElement.GetGuid();
|
||||
}
|
||||
|
||||
throw new InvalidCastException("Could not convert value to Guid");
|
||||
}
|
||||
|
||||
private static IEnumerable<Guid?> ToGuidList(object? value)
|
||||
{
|
||||
if (value is IEnumerable<Guid?> guidList)
|
||||
{
|
||||
return guidList;
|
||||
}
|
||||
if (value is JsonElement { ValueKind: JsonValueKind.Array } jsonElement)
|
||||
{
|
||||
var list = new List<Guid?>();
|
||||
foreach (var item in jsonElement.EnumerateArray())
|
||||
{
|
||||
list.Add(ToGuid(item));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
throw new InvalidCastException("Could not convert value to Guid[]");
|
||||
}
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
# Design goals
|
||||
|
||||
The main goal of event integrations is to easily enable adding new integrations over time without the need
|
||||
for a lot of custom work to expose events to a new integration. The ability of fan-out offered by AMQP
|
||||
(either in RabbitMQ or in Azure Service Bus) gives us a way to attach any number of new integrations to the
|
||||
existing event system without needing to add special handling. By adding a new listener to the existing
|
||||
pipeline, it gains an independent stream of events without the need for additional broadcast code.
|
||||
|
||||
We want to enable robust handling of failures and retries. By utilizing the two-tier approach
|
||||
([described below](#two-tier-exchange)), we build in support at the service level for retries. When we add
|
||||
new integrations, they can focus solely on the integration-specific logic and reporting status, with all the
|
||||
process of retries and delays managed by the messaging system.
|
||||
|
||||
Another goal is to not only support this functionality in the cloud version, but offer it as well to
|
||||
self-hosted instances. RabbitMQ provides a lightweight way for self-hosted instances to tie into the event system
|
||||
using the same robust architecture for integrations without the need for Azure Service Bus.
|
||||
|
||||
Finally, we want to offer organization admins flexibility and control over what events are significant, where
|
||||
to send events, and the data to be included in the message. The configuration architecture allows Organizations
|
||||
to customize details of a specific integration; see [Integrations and integration
|
||||
configurations](#integrations-and-integration-configurations) below for more details on the configuration piece.
|
||||
|
||||
# Architecture
|
||||
|
||||
The entry point for the event integrations is the `IEventWriteService`. By configuring the
|
||||
`EventIntegrationEventWriteService` as the `EventWriteService`, all events sent to the
|
||||
service are broadcast on the RabbitMQ or Azure Service Bus message exchange. To abstract away
|
||||
the specifics of publishing to a specific AMQP provider, an `IEventIntegrationPublisher`
|
||||
is injected into `EventIntegrationEventWriteService` to handle the publishing of events to the
|
||||
RabbitMQ or Azure Service Bus service.
|
||||
|
||||
## Two-tier exchange
|
||||
|
||||
When `EventIntegrationEventWriteService` publishes, it posts to the first tier of our two-tier
|
||||
approach to handling messages. Each tier is represented in the AMQP stack by a separate exchange
|
||||
(in RabbitMQ terminology) or topic (in Azure Service Bus).
|
||||
|
||||
``` mermaid
|
||||
flowchart TD
|
||||
B1[EventService]
|
||||
B2[EventIntegrationEventWriteService]
|
||||
B3[Event Exchange / Topic]
|
||||
B4[EventRepositoryHandler]
|
||||
B5[WebhookIntegrationHandler]
|
||||
B6[Events in Database / Azure Tables]
|
||||
B7[HTTP Server]
|
||||
B8[SlackIntegrationHandler]
|
||||
B9[Slack]
|
||||
B10[EventIntegrationHandler]
|
||||
B12[Integration Exchange / Topic]
|
||||
|
||||
B1 -->|IEventWriteService| B2 --> B3
|
||||
B3-->|EventListenerService| B4 --> B6
|
||||
B3-->|EventListenerService| B10
|
||||
B3-->|EventListenerService| B10
|
||||
B10 --> B12
|
||||
B12 -->|IntegrationListenerService| B5
|
||||
B12 -->|IntegrationListenerService| B8
|
||||
B5 -->|HTTP POST| B7
|
||||
B8 -->|HTTP POST| B9
|
||||
```
|
||||
|
||||
### Event tier
|
||||
|
||||
In the first tier, events are broadcast in a fan-out to a series of listeners. The message body
|
||||
is a JSON representation of an individual `EventMessage` or an array of `EventMessage`. Handlers at
|
||||
this level are responsible for handling each event or array of events. There are currently two handlers
|
||||
at this level:
|
||||
- `EventRepositoryHandler`
|
||||
- The `EventRepositoryHandler` is responsible for long term storage of events. It receives all events
|
||||
and stores them via an injected `IEventRepository` into the database.
|
||||
- This mirrors the behavior of when event integrations are turned off - cloud stores to Azure Tables
|
||||
and self-hosted is stored to the database.
|
||||
- `EventIntegrationHandler`
|
||||
- The `EventIntegrationHandler` is a generic class that is customized to each integration (via the
|
||||
configuration details of the integration) and is responsible for determining if there's a configuration
|
||||
for this event / organization / integration, fetching that configuration, and parsing the details of the
|
||||
event into a template string.
|
||||
- The `EventIntegrationHandler` uses the injected `IOrganizationIntegrationConfigurationRepository` to pull
|
||||
the specific set of configuration and template based on the event type, organization, and integration type.
|
||||
This configuration is what determines if an integration should be sent, what details are necessary for sending
|
||||
it, and the actual message to send.
|
||||
- The output of `EventIntegrationHandler` is a new `IntegrationMessage`, with the details of this
|
||||
the configuration necessary to interact with the integration and the message to send (with all the event
|
||||
details incorporated), published to the integration level of the message bus.
|
||||
|
||||
### Integration tier
|
||||
|
||||
At the integration level, messages are JSON representations of `IIntegrationMessage` - specifically they
|
||||
will be concrete types of the generic `IntegrationMessage<T>` where `<T>` is the configuration details of the
|
||||
specific integration for which they've been sent. These messages represent the details required for
|
||||
sending a specific event to a specific integration, including handling retries and delays.
|
||||
|
||||
Handlers at the integration level are tied directly to the integration (e.g. `SlackIntegrationHandler`,
|
||||
`WebhookIntegrationHandler`). These handlers take in `IntegrationMessage<T>` and output
|
||||
`IntegrationHandlerResult`, which tells the listener the outcome of the integration (e.g. success / fail,
|
||||
if it can be retried and any minimum delay that should occur). This makes them easy to unit test in isolation
|
||||
without any of the concerns of AMQP or messaging.
|
||||
|
||||
The listeners at this level are responsible for firing off the handler when a new message comes in and then
|
||||
taking the correct action based on the result. Successful results simply acknowledge the message and resolve.
|
||||
Failures will either be sent to the dead letter queue (DLQ) or re-published for retry after the correct amount of delay.
|
||||
|
||||
### Retries
|
||||
|
||||
One of the goals of introducing the integration level is to simplify and enable the process of multiple retries
|
||||
for a specific event integration. For instance, if a service is temporarily down, we don't want one of our handlers
|
||||
blocking the rest of the queue while it waits to retry. In addition, we don't want to retry _all_ integrations for a
|
||||
specific event if only one integration fails nor do we want to re-lookup the configuration details. By splitting
|
||||
out the `IntegrationMessage<T>` with the configuration, message, and details around retries, we can process each
|
||||
event / integration individually and retry easily.
|
||||
|
||||
When the `IntegrationHandlerResult.Success` is set to `false` (indicating that the integration attempt failed) the
|
||||
`Retryable` flag tells the listener whether this failure is temporary or final. If the `Retryable` is `false`, then
|
||||
the message is immediately sent to the DLQ. If it is `true`, the listener uses the `ApplyRetry(DateTime)` method
|
||||
in `IntegrationMessage` which handles both incrementing the `RetryCount` and updating the `DelayUntilDate` using
|
||||
the provided DateTime, but also adding exponential backoff (based on `RetryCount`) and jitter. The listener compares
|
||||
the `RetryCount` in the `IntegrationMessage` to see if it's over the `MaxRetries` defined in Global Settings. If it
|
||||
is over the `MaxRetries`, the message is sent to the DLQ. Otherwise, it is scheduled for retry.
|
||||
|
||||
``` mermaid
|
||||
flowchart TD
|
||||
A[Success == false] --> B{Retryable?}
|
||||
B -- No --> C[Send to Dead Letter Queue DLQ]
|
||||
B -- Yes --> D[Check RetryCount vs MaxRetries]
|
||||
D -->|RetryCount >= MaxRetries| E[Send to Dead Letter Queue DLQ]
|
||||
D -->|RetryCount < MaxRetries| F[Schedule for Retry]
|
||||
```
|
||||
|
||||
Azure Service Bus supports scheduling messages as part of its core functionality. Retries are scheduled to a specific
|
||||
time and then ASB holds the message and publishes it at the correct time.
|
||||
|
||||
#### RabbitMQ retry options
|
||||
|
||||
For RabbitMQ (which will be used by self-host only), we have two different options. The `useDelayPlugin` flag in
|
||||
`GlobalSettings.RabbitMqSettings` determines which one is used. If it is set to `true`, we use the delay plugin. It
|
||||
defaults to `false` which indicates we should use retry queues with a timing check.
|
||||
|
||||
1. Delay plugin
|
||||
- [Delay plugin GitHub repo](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange)
|
||||
- This plugin enables a delayed message exchange in RabbitMQ that supports delaying a message for an amount
|
||||
of time specified in a special header.
|
||||
- This allows us to forego using any retry queues and rely instead on the delay exchange. When a message is
|
||||
marked with the header it gets published to the exchange and the exchange handles all the functionality of
|
||||
holding it until the appropriate time (similar to ASB's built-in support).
|
||||
- The plugin must be setup and enabled before turning this option on (which is why it defaults to off).
|
||||
|
||||
2. Retry queues + timing check
|
||||
- If the delay plugin setting is off, we push the message to a retry queue which has a fixed amount of time before
|
||||
it gets re-published back to the main queue.
|
||||
- When a message comes off the queue, we check to see if the `DelayUntilDate` has already passed.
|
||||
- If it has passed, we then handle the integration normally and retry the request.
|
||||
- If it is still in the future, we put the message back on the retry queue for an additional wait.
|
||||
- While this does use extra processing, it gives us better support for honoring the delays even if the delay plugin
|
||||
isn't enabled. Since this solution is only intended for self-host, it should be a pretty minimal impact with short
|
||||
delays and a small number of retries.
|
||||
|
||||
## Listener / Handler pattern
|
||||
|
||||
To make it easy to support multiple AMQP services (RabbitMQ and Azure Service Bus), the act
|
||||
of listening to the stream of messages is decoupled from the act of responding to a message.
|
||||
|
||||
### Listeners
|
||||
|
||||
- Listeners handle the details of the communication platform (i.e. RabbitMQ and Azure Service Bus).
|
||||
- There is one listener for each platform (RabbitMQ / ASB) for each of the two levels - i.e. one event listener
|
||||
and one integration listener.
|
||||
- Perform all the aspects of setup / teardown, subscription, message acknowledgement, etc. for the messaging platform,
|
||||
but do not directly process any events themselves. Instead, they delegate to the handler with which they
|
||||
are configured.
|
||||
- Multiple instances can be configured to run independently, each with its own handler and
|
||||
subscription / queue.
|
||||
|
||||
### Handlers
|
||||
|
||||
- One handler per queue / subscription (e.g. per integration at the integration level).
|
||||
- Completely isolated from and know nothing of the messaging platform in use. This allows them to be
|
||||
freely reused across different communication platforms.
|
||||
- Perform all aspects of handling an event.
|
||||
- Allows them to be highly testable as they are isolated and decoupled from the more complicated
|
||||
aspects of messaging.
|
||||
|
||||
This combination allows for a configuration inside of `ServiceCollectionExtensions.cs` that pairs
|
||||
instances of the listener service for the currently running messaging platform with any number of
|
||||
handlers. It also allows for quick development of new handlers as they are focused only on the
|
||||
task of handling a specific event.
|
||||
|
||||
## Publishers and Services
|
||||
|
||||
Listeners (and `EventIntegrationHandler`) interact with the messaging system via the `IEventPublisher` interface,
|
||||
which is backed by a RabbitMQ and ASB specific service. By placing most of the messaging platform details in the
|
||||
service layer, we are able to handle common things like configuring the connection, binding or creating a specific
|
||||
queue, etc. in one place. The `IRabbitMqService` and `IAzureServiceBusService` implement the `IEventPublisher`
|
||||
interface and therefore can also handle directly all the message publishing functionality.
|
||||
|
||||
## Integrations and integration configurations
|
||||
|
||||
Organizations can configure integration configurations to send events to different endpoints -- each
|
||||
handler maps to a specific integration and checks for the configuration when it receives an event.
|
||||
Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event Collector (HEC).
|
||||
|
||||
### `OrganizationIntegration`
|
||||
|
||||
- The top-level object that enables a specific integration for the organization.
|
||||
- Includes any properties that apply to the entire integration across all events.
|
||||
- For example, Slack stores the token in the `Configuration` which applies to every event, but stores the
|
||||
channel id in the `Configuration` of the `OrganizationIntegrationConfiguration`. The token applies to the entire Slack
|
||||
integration, but the channel could be configured differently depending on event type.
|
||||
- See the table below for more examples / details on what is stored at which level.
|
||||
|
||||
### `OrganizationIntegrationConfiguration`
|
||||
|
||||
- This contains the configurations specific to each `EventType` for the integration.
|
||||
- `Configuration` contains the event-specific configuration.
|
||||
- Any properties at this level override the `Configuration` form the `OrganizationIntegration`.
|
||||
- See the table below for examples of specific integrations.
|
||||
- `Template` contains a template string that is expected to be filled in with the contents of the actual event.
|
||||
- The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`.
|
||||
- The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from
|
||||
the provided `EventMessage`.
|
||||
- The template does not enforce any structure — it could be a freeform text message to send via Slack, or a
|
||||
JSON body to send via webhook; it is simply stored and used as a string for the most flexibility.
|
||||
|
||||
### `OrganizationIntegrationConfigurationDetails`
|
||||
|
||||
- This is the combination of both the `OrganizationIntegration` and `OrganizationIntegrationConfiguration` into
|
||||
a single object. The combined contents tell the integration's handler all the details needed to send to an
|
||||
external service.
|
||||
- `OrganizationIntegrationConfiguration` takes precedence over `OrganizationIntegration` - any keys present in
|
||||
both will receive the value declared in `OrganizationIntegrationConfiguration`.
|
||||
- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
|
||||
the database to determine what to publish at the integration level.
|
||||
|
||||
### Existing integrations and the configurations at each level
|
||||
|
||||
The following table illustrates how each integration is configured and what exactly is stored in the `Configuration`
|
||||
property at each level (`OrganizationIntegration` or `OrganizationIntegrationConfiguration`). Under
|
||||
`OrganizationIntegration` the valid `OrganizationIntegrationStatus` are in bold, with an example of what would be
|
||||
stored at each status.
|
||||
|
||||
| **Integration** | **OrganizationIntegration** | **OrganizationIntegrationConfiguration** |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| CloudBillingSync | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
|
||||
| Scim | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
|
||||
| Slack | **Initiated**: `null`<br/>**Completed**:<br/>`{ "Token": "xoxb-token-from-slack" }` | `{ "channelId": "C123456" }` |
|
||||
| Webhook | `null` or `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | `null` or `{ "Scheme": "Bearer", "Token":"AUTH-TOKEN", "Uri": "https://example.com" }`<br/><br/>Whatever is defined at this level takes precedence |
|
||||
| Hec | `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | Always `null` |
|
||||
| Datadog | `{ "ApiKey": "TheKey12345", "Uri": "https://api.us5.datadoghq.com/api/v1/events"}` | Always `null` |
|
||||
| Teams | **Initiated**: `null`<br/>**In Progress**: <br/> `{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`<br/>**Completed**: <br/>`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"], "ServiceUrl":"https://example.com", ChannelId: "channel-1234"}` | Always `null` |
|
||||
|
||||
## Filtering
|
||||
|
||||
In addition to the ability to configure integrations mentioned above, organization admins can
|
||||
also add `Filters` stored in the `OrganizationIntegrationConfiguration`. Filters are completely
|
||||
optional and as simple or complex as organization admins want to make them. These are stored in
|
||||
the database as JSON and serialized into an `IntegrationFilterGroup`. This is then passed to
|
||||
the `IntegrationFilterService`, which evaluates it to a `bool`. If it's `true`, the integration
|
||||
proceeds as above. If it's `false`, we ignore this event and do not route it to the integration
|
||||
level.
|
||||
|
||||
### `IntegrationFilterGroup`
|
||||
|
||||
Logical AND / OR grouping of a number of rules and other subgroups.
|
||||
|
||||
| Property | Description |
|
||||
|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `AndOperator` | Indicates whether **all** (`true`) or **any** (`false`) of the `Rules` and `Groups` must be true. This applies to _both_ the inner group and the list of rules; for instance, if this group contained Rule1 and Rule2 as well as Group1 and Group2:<br/><br/>`true`: `Rule1 && Rule2 && Group1 && Group2`<br>`false`: `Rule1 \|\| Rule2 \|\| Group1 \|\| Group2` |
|
||||
| `Rules` | A list of `IntegrationFilterRule`. Can be null or empty, in which case it will return `true`. |
|
||||
| `Groups` | A list of nested `IntegrationFilterGroup`. Can be null or empty, in which case it will return `true`. |
|
||||
|
||||
### `IntegrationFilterRule`
|
||||
|
||||
The core of the filtering framework to determine if the data in this specific EventMessage
|
||||
matches the data for which the filter is searching.
|
||||
|
||||
| Property | Description |
|
||||
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `Property` | The property on `EventMessage` to evaluate (e.g., `CollectionId`). |
|
||||
| `Operation` | The comparison to perform between the property and `Value`. <br><br>**Supported operations:**<br>• `Equals`: `Guid` equals `Value`<br>• `NotEquals`: logical inverse of `Equals`<br>• `In`: `Guid` is in `Value` list<br>• `NotIn`: logical inverse of `In` |
|
||||
| `Value` | The comparison value. Type depends on `Operation`: <br>• `Equals`, `NotEquals`: `Guid`<br>• `In`, `NotIn`: list of `Guid` |
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[IntegrationFilterGroup]
|
||||
A -->|Has 0..many| B1[IntegrationFilterRule]
|
||||
A --> D1[And Operator]
|
||||
A -->|Has 0..many| C1[Nested IntegrationFilterGroup]
|
||||
|
||||
B1 --> B2[Property: string]
|
||||
B1 --> B3[Operation: Equals/In/DateBefore/DateAfter]
|
||||
B1 --> B4[Value: object?]
|
||||
|
||||
C1 -->|Has many| B1_2[IntegrationFilterRule]
|
||||
C1 -->|Can contain| C2[IntegrationFilterGroup...]
|
||||
```
|
||||
## Caching
|
||||
|
||||
To reduce database load and improve performance, event integrations uses its own named extended cache (see
|
||||
[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md)
|
||||
for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database
|
||||
query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`.
|
||||
|
||||
### `EventIntegrationsCacheConstants`
|
||||
|
||||
`EventIntegrationsCacheConstants` allows the code to have strongly typed references to a number of cache-related
|
||||
details when working with the extended cache. The cache name and all cache keys and tags are programmatically accessed
|
||||
from `EventIntegrationsCacheConstants` rather than simple strings. For instance,
|
||||
`EventIntegrationsCacheConstants.CacheName` is used in the cache setup, keyed services, dependency injection, etc.,
|
||||
rather than using a string literal (i.e. "EventIntegrations") in code.
|
||||
|
||||
### `OrganizationIntegrationConfigurationDetails`
|
||||
|
||||
- This is one of the most actively used portions of the architecture because any event that has an associated
|
||||
organization requires a check of the configurations to determine if we need to fire off an integration.
|
||||
- By using the extended cache, all reads are hitting the L1 or L2 cache before needing to access the database.
|
||||
- Reads return a `List<OrganizationIntegrationConfigurationDetails>` for a given key or an empty list if no
|
||||
match exists.
|
||||
- The TTL is set very high on these records (1 day). This is because when the admin API makes any changes, it
|
||||
tells the cache to remove that key. This propagates to the event listening code via the extended cache backplane,
|
||||
which means that the cache is then expired and the next read will fetch the new values. This allows us to have
|
||||
a high TTL and avoid needing to refresh values except when necessary.
|
||||
|
||||
#### Tagging per integration
|
||||
|
||||
- Each entry in the cache (which again, returns `List<OrganizationIntegrationConfigurationDetails>`) is tagged with
|
||||
the organization id and the integration type.
|
||||
- This allows us to remove all of a given organization's configuration details for an integration when the admin
|
||||
makes changes at the integration level.
|
||||
- For instance, if there were 5 events configured for a given organization's webhook and the admin changed the URL
|
||||
at the integration level, the updates would need to be propagated or else the cache will continue returning the
|
||||
stale URL.
|
||||
- By tagging each of the entries, the API can ask the extended cache to remove all the entries for a given
|
||||
organization integration in one call. The cache will handle dropping / refreshing these entries in a
|
||||
performant way.
|
||||
- There are two places in the code that are both aware of the tagging functionality
|
||||
- The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache
|
||||
to store the entry with the tag when it successfully loads from the repository.
|
||||
- The `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and
|
||||
`DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an admin
|
||||
creates, updates, or deletes an integration.
|
||||
- To ensure both places are synchronized on how to tag entries, they both use
|
||||
`EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag.
|
||||
|
||||
### Template Properties
|
||||
|
||||
- The `IntegrationTemplateProcessor` supports some properties that require an additional lookup. For instance,
|
||||
the `UserId` is provided as part of the `EventMessage`, but `UserName` means an additional lookup to map the user
|
||||
id to the actual name.
|
||||
- The properties for a `User` (which includes `ActingUser`), `Group`, and `Organization` are cached via the
|
||||
extended cache with a default TTL of 30 minutes.
|
||||
- This is cached in both the L1 (Memory) and L2 (Redis) and will be automatically refreshed as needed.
|
||||
|
||||
# Building a new integration
|
||||
|
||||
These are all the pieces required in the process of building out a new integration. For
|
||||
clarity in naming, these assume a new integration called "Example". To see a complete example
|
||||
in context, view [the PR for adding the Datadog integration](https://github.com/bitwarden/server/pull/6289).
|
||||
|
||||
## IntegrationType
|
||||
|
||||
Add a new type to `IntegrationType` for the new integration.
|
||||
|
||||
## Configuration Models
|
||||
|
||||
The configuration models are the classes that will determine what is stored in the database for
|
||||
`OrganizationIntegration` and `OrganizationIntegrationConfiguration`. The `Configuration` columns are the
|
||||
serialized version of the corresponding objects and represent the coonfiguration details for this integration
|
||||
and event type.
|
||||
|
||||
1. `ExampleIntegration`
|
||||
- Configuration details for the whole integration (e.g. a token in Slack).
|
||||
- Applies to every event type configuration defined for this integration.
|
||||
- Maps to the JSON structure stored in `Configuration` in ``OrganizationIntegration`.
|
||||
2. `ExampleIntegrationConfiguration`
|
||||
- Configuration details that could change from event to event (e.g. channelId in Slack).
|
||||
- Maps to the JSON structure stored in `Configuration` in `OrganizationIntegrationConfiguration`.
|
||||
3. `ExampleIntegrationConfigurationDetails`
|
||||
- Combined configuration of both Integration _and_ IntegrationConfiguration.
|
||||
- This will be the deserialized version of the `MergedConfiguration` in
|
||||
`OrganizationIntegrationConfigurationDetails`.
|
||||
|
||||
A new row with the new integration should be added to this doc in the table above [Existing integrations
|
||||
and the configurations at each level](#existing-integrations-and-the-configurations-at-each-level).
|
||||
|
||||
## Request Models
|
||||
|
||||
1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`.
|
||||
- Additionally, add tests in `OrganizationIntegrationRequestModelTests`
|
||||
2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`.
|
||||
- Additionally, add / update tests in `OrganizationIntegrationConfigurationRequestModelTests`
|
||||
|
||||
## Response Model
|
||||
|
||||
1. Add a new case to the switch method in `OrganizationIntegrationResponseModel.Status`.
|
||||
- Additionally, add / update tests in `OrganizationIntegrationResponseModelTests`
|
||||
|
||||
## Integration Handler
|
||||
|
||||
e.g. `ExampleIntegrationHandler`
|
||||
- This is where the actual code will go to perform the integration (i.e. send an HTTP request, etc.).
|
||||
- Handlers receive an `IntegrationMessage<T>` where `<T>` is the `ExampleIntegrationConfigurationDetails`
|
||||
defined above. This has the Configuration as well as the rendered template message to be sent.
|
||||
- Handlers return an `IntegrationHandlerResult` with details about if the request - success / failure,
|
||||
if it can be retried, when it should be delayed until, etc.
|
||||
- The scope of the handler is simply to do the integration and report the result.
|
||||
Everything else (such as how many times to retry, when to retry, what to do with failures)
|
||||
is done in the Listener.
|
||||
|
||||
## GlobalSettings
|
||||
|
||||
### RabbitMQ
|
||||
Add the queue names for the integration. These are typically set with a default value so
|
||||
that they will be created when first accessed in code by RabbitMQ.
|
||||
|
||||
1. `ExampleEventQueueName`
|
||||
2. `ExampleIntegrationQueueName`
|
||||
3. `ExampleIntegrationRetryQueueName`
|
||||
|
||||
### Azure Service Bus
|
||||
Add the subscription names to use for ASB for this integration. Similar to RabbitMQ a
|
||||
default value is provided so that we don't require configuring it in secrets but allow
|
||||
it to be overridden. **However**, unlike RabbitMQ these subscriptions must exist prior
|
||||
to the code accessing them. They will not be created on the fly. See [Deploying a new
|
||||
integration](#deploying-a-new-integration) below
|
||||
|
||||
1. `ExmpleEventSubscriptionName`
|
||||
2. `ExmpleIntegrationSubscriptionName`
|
||||
|
||||
#### Service Bus Emulator, local config
|
||||
In order to create ASB resources locally, we need to also update the `servicebusemulator_config.json` file
|
||||
to include any new subscriptions.
|
||||
- Under the existing event topic (`event-logging`) add a subscription for the event level for this
|
||||
new integration (`events-example-subscription`).
|
||||
- Under the existing integration topic (`event-integrations`) add a new subscription for the integration
|
||||
level messages (`integration-example-subscription`).
|
||||
- Copy the correlation filter from the other integration level subscriptions. It should filter based on
|
||||
the `IntegrationType.ToRoutingKey`, or in this example `example`.
|
||||
|
||||
These names added here are what must match the values provided in the secrets or the defaults provided
|
||||
in Global Settings. This must be in place (and the local ASB emulator restarted) before you can use any
|
||||
code locally that accesses ASB resources.
|
||||
|
||||
## ListenerConfiguration
|
||||
|
||||
New integrations will need their own subclass of `ListenerConfiguration` which also conforms to
|
||||
`IIntegrationListenerConfiguration`. This class provides a way of accessing the previously configured
|
||||
RabbitMQ queues and ASB subscriptions by referring to the values created in `GlobalSettings`. This new
|
||||
listener configuration will be used to type the listener and provide the means to access the necessary
|
||||
configurations for the integration.
|
||||
|
||||
## ServiceCollectionExtensions
|
||||
|
||||
In our `ServiceCollectionExtensions`, we pull all the above pieces together to start listeners on each message
|
||||
tier with handlers to process the integration.
|
||||
|
||||
The core method for all event integration setup is `AddEventIntegrationServices`. This method is called by
|
||||
both of the add listeners methods, which ensures that we have one common place to set up cross-messaging-platform
|
||||
dependencies and integrations. For instance, `SlackIntegrationHandler` needs a `SlackService`, so
|
||||
`AddEventIntegrationServices` has a call to `AddSlackService`. Same thing for webhooks when it
|
||||
comes to defining a custom HttpClient by name.
|
||||
|
||||
In `AddEventIntegrationServices`:
|
||||
|
||||
1. Create the singleton for the handler:
|
||||
|
||||
``` csharp
|
||||
services.TryAddSingleton<IIntegrationHandler<ExampleIntegrationConfigurationDetails>, ExampleIntegrationHandler>();
|
||||
```
|
||||
|
||||
2. Create the listener configuration:
|
||||
|
||||
``` csharp
|
||||
var exampleConfiguration = new ExampleListenerConfiguration(globalSettings);
|
||||
```
|
||||
|
||||
3. Add the integration to both the RabbitMQ and ASB specific declarations:
|
||||
|
||||
``` csharp
|
||||
services.AddRabbitMqIntegration<ExampleIntegrationConfigurationDetails, ExampleListenerConfiguration>(exampleConfiguration);
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
``` csharp
|
||||
services.AddAzureServiceBusIntegration<ExampleIntegrationConfigurationDetails, ExampleListenerConfiguration>(exampleConfiguration);
|
||||
```
|
||||
|
||||
|
||||
# Deploying a new integration
|
||||
|
||||
## RabbitMQ
|
||||
|
||||
RabbitMQ dynamically creates queues and exchanges when they are first accessed in code.
|
||||
Therefore, there is no need to manually create queues when deploying a new integration.
|
||||
They can be created and configured ahead of time, but it's not required. Note that once
|
||||
they are created, if any configurations need to be changed, the queue or exchange must be
|
||||
deleted and recreated.
|
||||
|
||||
## Azure Service Bus
|
||||
|
||||
Unlike RabbitMQ, ASB resources **must** be allocated before the code accesses them and
|
||||
will not be created on the fly. This means that any subscriptions needed for a new
|
||||
integration must be created in ASB before that code is deployed.
|
||||
|
||||
The two subscriptions created above in Global Settings and `servicebusemulator_config.json`
|
||||
need to be created in the Azure portal or CLI for the environment before deploying the
|
||||
code.
|
||||
|
||||
1. `ExmpleEventSubscriptionName`
|
||||
- This subscription is a fan-out subscription from the main event topic.
|
||||
- As such, it will start receiving all the events as soon as it is declared.
|
||||
- This can create a backlog before the integration-specific handler is declared and deployed.
|
||||
- One strategy to avoid this is to create the subscription with a false filter (e.g. `1 = 0`).
|
||||
- This will create the subscription, but the filter will ensure that no messages
|
||||
actually land in the subscription.
|
||||
- Code can be deployed that references the subscription, because the subscription
|
||||
legitimately exists (it is simply empty).
|
||||
- When the code is in place, and we're ready to start receiving messages on the new
|
||||
integration, we simply remove the filter to return the subscription to receiving
|
||||
all messages via fan-out.
|
||||
2. `ExmpleIntegrationSubscriptionName`
|
||||
- This subscription must be created before the new integration code can be deployed.
|
||||
- However, it is not fan-out, but rather a filter based on the `IntegrationType.ToRoutingKey`.
|
||||
- Therefore, it won't start receiving messages until organizations have active configurations.
|
||||
This means there's no risk of building up a backlog by declaring it ahead of time.
|
||||
@@ -1,74 +0,0 @@
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqEventListenerService<TConfiguration> : EventLoggingListenerService
|
||||
where TConfiguration : IEventListenerConfiguration
|
||||
{
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
private readonly string _queueName;
|
||||
private readonly IRabbitMqService _rabbitMqService;
|
||||
|
||||
public RabbitMqEventListenerService(
|
||||
IEventMessageHandler handler,
|
||||
TConfiguration configuration,
|
||||
IRabbitMqService rabbitMqService,
|
||||
ILoggerFactory loggerFactory)
|
||||
: base(handler, CreateLogger(loggerFactory, configuration))
|
||||
{
|
||||
_queueName = configuration.EventQueueName;
|
||||
_rabbitMqService = rabbitMqService;
|
||||
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _rabbitMqService.CreateEventQueueAsync(_queueName, cancellationToken);
|
||||
await base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, eventArgs) => { await ProcessReceivedMessageAsync(eventArgs); };
|
||||
|
||||
await channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs eventArgs)
|
||||
{
|
||||
await ProcessReceivedMessageAsync(
|
||||
Encoding.UTF8.GetString(eventArgs.Body.Span),
|
||||
eventArgs.BasicProperties.MessageId);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_lazyChannel.IsValueCreated)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
await channel.CloseAsync(cancellationToken);
|
||||
}
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully)
|
||||
{
|
||||
_lazyChannel.Value.Result.Dispose();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration)
|
||||
{
|
||||
return loggerFactory.CreateLogger(
|
||||
categoryName: $"Bit.Core.Services.RabbitMqEventListenerService.{configuration.EventQueueName}");
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqIntegrationListenerService<TConfiguration> : BackgroundService
|
||||
where TConfiguration : IIntegrationListenerConfiguration
|
||||
{
|
||||
private readonly int _maxRetries;
|
||||
private readonly string _queueName;
|
||||
private readonly string _routingKey;
|
||||
private readonly string _retryQueueName;
|
||||
private readonly IIntegrationHandler _handler;
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
private readonly IRabbitMqService _rabbitMqService;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RabbitMqIntegrationListenerService(
|
||||
IIntegrationHandler handler,
|
||||
TConfiguration configuration,
|
||||
IRabbitMqService rabbitMqService,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_handler = handler;
|
||||
_maxRetries = configuration.MaxRetries;
|
||||
_routingKey = configuration.RoutingKey;
|
||||
_retryQueueName = configuration.IntegrationRetryQueueName;
|
||||
_queueName = configuration.IntegrationQueueName;
|
||||
_rabbitMqService = rabbitMqService;
|
||||
_timeProvider = timeProvider;
|
||||
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
|
||||
_logger = loggerFactory.CreateLogger(
|
||||
categoryName: $"Bit.Core.Services.RabbitMqIntegrationListenerService.{configuration.IntegrationQueueName}"); ;
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _rabbitMqService.CreateIntegrationQueuesAsync(
|
||||
_queueName,
|
||||
_retryQueueName,
|
||||
_routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, ea) =>
|
||||
{
|
||||
await ProcessReceivedMessageAsync(ea, cancellationToken);
|
||||
};
|
||||
|
||||
await channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs ea, CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
|
||||
// Determine if the message came off of the retry queue too soon
|
||||
// If so, place it back on the retry queue
|
||||
var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json);
|
||||
if (integrationMessage is not null &&
|
||||
integrationMessage.DelayUntilDate.HasValue &&
|
||||
integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime)
|
||||
{
|
||||
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _handler.HandleAsync(json);
|
||||
var message = result.Message;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Successful integration send. Acknowledge message delivery and return
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Retryable)
|
||||
{
|
||||
// Integration failed, but is retryable - apply delay and check max retries
|
||||
message.ApplyRetry(result.DelayUntilDate);
|
||||
|
||||
if (message.RetryCount < _maxRetries)
|
||||
{
|
||||
// Publish message to the retry queue. It will be re-published for retry after a delay
|
||||
await _rabbitMqService.PublishToRetryAsync(channel, message, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Exceeded the max number of retries; fail and send to dead letter queue
|
||||
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
||||
_logger.LogWarning(
|
||||
"Integration failure - max retries exceeded. " +
|
||||
"MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " +
|
||||
"FailureCategory: {Category}, Reason: {Reason}, RetryCount: {RetryCount}, MaxRetries: {MaxRetries}",
|
||||
message.MessageId,
|
||||
message.IntegrationType,
|
||||
message.OrganizationId,
|
||||
result.Category,
|
||||
result.FailureReason,
|
||||
message.RetryCount,
|
||||
_maxRetries);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries
|
||||
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
||||
_logger.LogWarning(
|
||||
"Integration failure - non-retryable. " +
|
||||
"MessageId: {MessageId}, IntegrationType: {IntegrationType}, OrganizationId: {OrgId}, " +
|
||||
"FailureCategory: {Category}, Reason: {Reason}",
|
||||
message.MessageId,
|
||||
message.IntegrationType,
|
||||
message.OrganizationId,
|
||||
result.Category,
|
||||
result.FailureReason);
|
||||
}
|
||||
|
||||
// Message has been sent to retry or dead letter queues.
|
||||
// Acknowledge receipt so Rabbit knows it's been processed
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error
|
||||
_logger.LogError(ex, "Unhandled error processing integration message.");
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_lazyChannel.IsValueCreated)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
await channel.CloseAsync(cancellationToken);
|
||||
}
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully)
|
||||
{
|
||||
_lazyChannel.Value.Result.Dispose();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqService : IRabbitMqService
|
||||
{
|
||||
private const string _deadLetterRoutingKey = "dead-letter";
|
||||
|
||||
private readonly ConnectionFactory _factory;
|
||||
private readonly Lazy<Task<IConnection>> _lazyConnection;
|
||||
private readonly string _deadLetterQueueName;
|
||||
private readonly string _eventExchangeName;
|
||||
private readonly string _integrationExchangeName;
|
||||
private readonly int _retryTiming;
|
||||
private readonly bool _useDelayPlugin;
|
||||
|
||||
public RabbitMqService(GlobalSettings globalSettings)
|
||||
{
|
||||
_factory = new ConnectionFactory
|
||||
{
|
||||
HostName = globalSettings.EventLogging.RabbitMq.HostName,
|
||||
UserName = globalSettings.EventLogging.RabbitMq.Username,
|
||||
Password = globalSettings.EventLogging.RabbitMq.Password
|
||||
};
|
||||
_deadLetterQueueName = globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName;
|
||||
_eventExchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
|
||||
_integrationExchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName;
|
||||
_retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming;
|
||||
_useDelayPlugin = globalSettings.EventLogging.RabbitMq.UseDelayPlugin;
|
||||
|
||||
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
|
||||
}
|
||||
|
||||
public async Task<IChannel> CreateChannelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
return await connection.CreateChannelAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var channel = await CreateChannelAsync(cancellationToken);
|
||||
await channel.QueueDeclareAsync(queue: queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(queue: queueName,
|
||||
exchange: _eventExchangeName,
|
||||
routingKey: string.Empty,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CreateIntegrationQueuesAsync(
|
||||
string queueName,
|
||||
string retryQueueName,
|
||||
string routingKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var channel = await CreateChannelAsync(cancellationToken);
|
||||
var retryRoutingKey = $"{routingKey}-retry";
|
||||
|
||||
// Declare main integration queue
|
||||
await channel.QueueDeclareAsync(
|
||||
queue: queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(
|
||||
queue: queueName,
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (!_useDelayPlugin)
|
||||
{
|
||||
// Declare retry queue (Configurable TTL, dead-letters back to main queue)
|
||||
// Only needed if NOT using delay plugin
|
||||
await channel.QueueDeclareAsync(queue: retryQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "x-dead-letter-exchange", _integrationExchangeName },
|
||||
{ "x-dead-letter-routing-key", routingKey },
|
||||
{ "x-message-ttl", _retryTiming }
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(queue: retryQueueName,
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: retryRoutingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message)
|
||||
{
|
||||
var routingKey = message.IntegrationType.ToRoutingKey();
|
||||
await using var channel = await CreateChannelAsync();
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(message.ToJson());
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
Persistent = true
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
routingKey: routingKey,
|
||||
body: body);
|
||||
}
|
||||
|
||||
public async Task PublishEventAsync(string body, string? organizationId)
|
||||
{
|
||||
await using var channel = await CreateChannelAsync();
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString(),
|
||||
Persistent = true
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _eventExchangeName,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
routingKey: string.Empty,
|
||||
body: Encoding.UTF8.GetBytes(body));
|
||||
}
|
||||
|
||||
public async Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var routingKey = message.IntegrationType.ToRoutingKey();
|
||||
var retryRoutingKey = $"{routingKey}-retry";
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true,
|
||||
MessageId = message.MessageId,
|
||||
Headers = _useDelayPlugin && message.DelayUntilDate.HasValue ?
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["x-delay"] = Math.Max((int)(message.DelayUntilDate.Value - DateTime.UtcNow).TotalMilliseconds, 0)
|
||||
} :
|
||||
null
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: _useDelayPlugin ? routingKey : retryRoutingKey,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
body: Encoding.UTF8.GetBytes(message.ToJson()),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PublishToDeadLetterAsync(
|
||||
IChannel channel,
|
||||
IIntegrationMessage message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
Persistent = true
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
routingKey: _deadLetterRoutingKey,
|
||||
body: Encoding.UTF8.GetBytes(message.ToJson()),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs)
|
||||
{
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: eventArgs.RoutingKey,
|
||||
mandatory: true,
|
||||
basicProperties: new BasicProperties(eventArgs.BasicProperties),
|
||||
body: eventArgs.Body);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_lazyConnection.IsValueCreated)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IConnection> CreateConnectionAsync()
|
||||
{
|
||||
var connection = await _factory.CreateConnectionAsync();
|
||||
using var channel = await connection.CreateChannelAsync();
|
||||
|
||||
// Declare Exchanges
|
||||
await channel.ExchangeDeclareAsync(exchange: _eventExchangeName, type: ExchangeType.Fanout, durable: true);
|
||||
if (_useDelayPlugin)
|
||||
{
|
||||
await channel.ExchangeDeclareAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
type: "x-delayed-message",
|
||||
durable: true,
|
||||
arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "x-delayed-type", "direct" }
|
||||
}
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.ExchangeDeclareAsync(exchange: _integrationExchangeName, type: ExchangeType.Direct, durable: true);
|
||||
}
|
||||
|
||||
// Declare dead letter queue for Integration exchange
|
||||
await channel.QueueDeclareAsync(queue: _deadLetterQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null);
|
||||
await channel.QueueBindAsync(queue: _deadLetterQueueName,
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: _deadLetterRoutingKey);
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class SlackIntegrationHandler(
|
||||
ISlackService slackService)
|
||||
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
|
||||
{
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
|
||||
message.Configuration.Token,
|
||||
message.RenderedTemplate,
|
||||
message.Configuration.ChannelId
|
||||
);
|
||||
|
||||
if (slackResponse is null)
|
||||
{
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
IntegrationFailureCategory.TransientError,
|
||||
"Slack response was null"
|
||||
);
|
||||
}
|
||||
|
||||
if (slackResponse.Ok)
|
||||
{
|
||||
return IntegrationHandlerResult.Succeed(message);
|
||||
}
|
||||
|
||||
var category = ClassifySlackError(slackResponse.Error);
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
category,
|
||||
slackResponse.Error
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a Slack API error code string as an <see cref="IntegrationFailureCategory"/> to drive
|
||||
/// retry behavior and operator-facing failure reporting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Slack responses commonly return an <c>error</c> string when <c>ok</c> is false. This method maps
|
||||
/// known Slack error codes to failure categories.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Any unrecognized error codes default to <see cref="IntegrationFailureCategory.TransientError"/> to avoid
|
||||
/// incorrectly marking new/unknown Slack failures as non-retryable.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="error">The Slack error code string (e.g. <c>invalid_auth</c>, <c>rate_limited</c>).</param>
|
||||
/// <returns>The corresponding <see cref="IntegrationFailureCategory"/>.</returns>
|
||||
private static IntegrationFailureCategory ClassifySlackError(string error)
|
||||
{
|
||||
return error switch
|
||||
{
|
||||
"invalid_auth" => IntegrationFailureCategory.AuthenticationFailed,
|
||||
"access_denied" => IntegrationFailureCategory.AuthenticationFailed,
|
||||
"token_expired" => IntegrationFailureCategory.AuthenticationFailed,
|
||||
"token_revoked" => IntegrationFailureCategory.AuthenticationFailed,
|
||||
"account_inactive" => IntegrationFailureCategory.AuthenticationFailed,
|
||||
"not_authed" => IntegrationFailureCategory.AuthenticationFailed,
|
||||
"channel_not_found" => IntegrationFailureCategory.ConfigurationError,
|
||||
"is_archived" => IntegrationFailureCategory.ConfigurationError,
|
||||
"rate_limited" => IntegrationFailureCategory.RateLimited,
|
||||
"ratelimited" => IntegrationFailureCategory.RateLimited,
|
||||
"message_limit_exceeded" => IntegrationFailureCategory.RateLimited,
|
||||
"internal_error" => IntegrationFailureCategory.TransientError,
|
||||
"service_unavailable" => IntegrationFailureCategory.ServiceUnavailable,
|
||||
"fatal_error" => IntegrationFailureCategory.ServiceUnavailable,
|
||||
_ => IntegrationFailureCategory.TransientError
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.Models.Slack;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class SlackService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<SlackService> logger) : ISlackService
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
private readonly string _clientId = globalSettings.Slack.ClientId;
|
||||
private readonly string _clientSecret = globalSettings.Slack.ClientSecret;
|
||||
private readonly string _scopes = globalSettings.Slack.Scopes;
|
||||
private readonly string _slackApiBaseUrl = globalSettings.Slack.ApiBaseUrl;
|
||||
|
||||
public const string HttpClientName = "SlackServiceHttpClient";
|
||||
private const string _slackOAuthBaseUri = "https://slack.com/oauth/v2/authorize";
|
||||
|
||||
public async Task<string> GetChannelIdAsync(string token, string channelName)
|
||||
{
|
||||
return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
|
||||
{
|
||||
var matchingChannelIds = new List<string>();
|
||||
var baseUrl = $"{_slackApiBaseUrl}/conversations.list";
|
||||
var nextCursor = string.Empty;
|
||||
|
||||
do
|
||||
{
|
||||
var uriBuilder = new UriBuilder(baseUrl);
|
||||
var queryParameters = HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
queryParameters["types"] = "public_channel,private_channel";
|
||||
queryParameters["limit"] = "1000";
|
||||
if (!string.IsNullOrEmpty(nextCursor))
|
||||
{
|
||||
queryParameters["cursor"] = nextCursor;
|
||||
}
|
||||
uriBuilder.Query = queryParameters.ToString();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<SlackChannelListResponse>();
|
||||
|
||||
if (result is { Ok: true })
|
||||
{
|
||||
matchingChannelIds.AddRange(result.Channels
|
||||
.Where(channel => channelNames.Contains(channel.Name))
|
||||
.Select(channel => channel.Id));
|
||||
nextCursor = result.ResponseMetadata.NextCursor;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Error getting Channel Ids: {Error}", result?.Error ?? "Unknown Error");
|
||||
nextCursor = string.Empty;
|
||||
}
|
||||
|
||||
} while (!string.IsNullOrEmpty(nextCursor));
|
||||
|
||||
return matchingChannelIds;
|
||||
}
|
||||
|
||||
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
|
||||
{
|
||||
var userId = await GetUserIdByEmailAsync(token, email);
|
||||
return await OpenDmChannelAsync(token, userId);
|
||||
}
|
||||
|
||||
public string GetRedirectUrl(string callbackUrl, string state)
|
||||
{
|
||||
var builder = new UriBuilder(_slackOAuthBaseUri);
|
||||
var query = HttpUtility.ParseQueryString(builder.Query);
|
||||
|
||||
query["client_id"] = _clientId;
|
||||
query["scope"] = _scopes;
|
||||
query["redirect_uri"] = callbackUrl;
|
||||
query["state"] = state;
|
||||
|
||||
builder.Query = query.ToString();
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
|
||||
new FormUrlEncodedContent([
|
||||
new KeyValuePair<string, string>("client_id", _clientId),
|
||||
new KeyValuePair<string, string>("client_secret", _clientSecret),
|
||||
new KeyValuePair<string, string>("code", code),
|
||||
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
|
||||
]));
|
||||
|
||||
SlackOAuthResponse? result;
|
||||
try
|
||||
{
|
||||
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON");
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
if (!result.Ok)
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: {Error}", result.Error);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
public async Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
|
||||
string channelId)
|
||||
{
|
||||
var payload = JsonContent.Create(new { channel = channelId, text = message });
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Content = payload;
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<SlackSendMessageResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing Slack message response: invalid JSON");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetUserIdByEmailAsync(string token, string email)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
SlackUserResponse? result;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON");
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error retrieving Slack user ID: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
if (!result.Ok)
|
||||
{
|
||||
logger.LogError("Error retrieving Slack user ID: {Error}", result.Error);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return result.User.Id;
|
||||
}
|
||||
|
||||
private async Task<string> OpenDmChannelAsync(string token, string userId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return string.Empty;
|
||||
|
||||
var payload = JsonContent.Create(new { users = userId });
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/conversations.open");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Content = payload;
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
SlackDmResponse? result;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON");
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error opening DM channel: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
if (!result.Ok)
|
||||
{
|
||||
logger.LogError("Error opening DM channel: {Error}", result.Error);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return result.Channel.Id;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Microsoft.Rest;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class TeamsIntegrationHandler(
|
||||
ITeamsService teamsService)
|
||||
: IntegrationHandlerBase<TeamsIntegrationConfigurationDetails>
|
||||
{
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(
|
||||
IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||
{
|
||||
try
|
||||
{
|
||||
await teamsService.SendMessageToChannelAsync(
|
||||
serviceUri: message.Configuration.ServiceUrl,
|
||||
message: message.RenderedTemplate,
|
||||
channelId: message.Configuration.ChannelId
|
||||
);
|
||||
|
||||
return IntegrationHandlerResult.Succeed(message);
|
||||
}
|
||||
catch (HttpOperationException ex)
|
||||
{
|
||||
var category = ClassifyHttpStatusCode(ex.Response.StatusCode);
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
category,
|
||||
ex.Message
|
||||
);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
IntegrationFailureCategory.ConfigurationError,
|
||||
ex.Message
|
||||
);
|
||||
}
|
||||
catch (UriFormatException ex)
|
||||
{
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
IntegrationFailureCategory.ConfigurationError,
|
||||
ex.Message
|
||||
);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
IntegrationFailureCategory.PermanentFailure,
|
||||
ex.Message
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return IntegrationHandlerResult.Fail(
|
||||
message,
|
||||
IntegrationFailureCategory.TransientError,
|
||||
ex.Message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Teams;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Teams;
|
||||
using Microsoft.Bot.Connector;
|
||||
using Microsoft.Bot.Connector.Authentication;
|
||||
using Microsoft.Bot.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamInfo = Bit.Core.Models.Teams.TeamInfo;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class TeamsService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<TeamsService> logger) : ActivityHandler, ITeamsService
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
private readonly string _clientId = globalSettings.Teams.ClientId;
|
||||
private readonly string _clientSecret = globalSettings.Teams.ClientSecret;
|
||||
private readonly string _scopes = globalSettings.Teams.Scopes;
|
||||
private readonly string _graphBaseUrl = globalSettings.Teams.GraphBaseUrl;
|
||||
private readonly string _loginBaseUrl = globalSettings.Teams.LoginBaseUrl;
|
||||
|
||||
public const string HttpClientName = "TeamsServiceHttpClient";
|
||||
|
||||
public string GetRedirectUrl(string redirectUrl, string state)
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
query["client_id"] = _clientId;
|
||||
query["response_type"] = "code";
|
||||
query["redirect_uri"] = redirectUrl;
|
||||
query["response_mode"] = "query";
|
||||
query["scope"] = string.Join(" ", _scopes);
|
||||
query["state"] = state;
|
||||
|
||||
return $"{_loginBaseUrl}/common/oauth2/v2.0/authorize?{query}";
|
||||
}
|
||||
|
||||
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post,
|
||||
$"{_loginBaseUrl}/common/oauth2/v2.0/token");
|
||||
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", _clientId },
|
||||
{ "client_secret", _clientSecret },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUrl },
|
||||
{ "grant_type", "authorization_code" }
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("Teams OAuth token exchange failed: {errorText}", errorText);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
TeamsOAuthResponse? result;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<TeamsOAuthResponse>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"{_graphBaseUrl}/me/joinedTeams");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("Get Teams request failed: {errorText}", errorText);
|
||||
return new List<TeamInfo>();
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<JoinedTeamsResponse>();
|
||||
|
||||
return result?.Value ?? [];
|
||||
}
|
||||
|
||||
public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
|
||||
{
|
||||
var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret);
|
||||
using var connectorClient = new ConnectorClient(serviceUri, credentials);
|
||||
|
||||
var activity = new Activity
|
||||
{
|
||||
Type = ActivityTypes.Message,
|
||||
Text = message
|
||||
};
|
||||
|
||||
await connectorClient.Conversations.SendToConversationAsync(channelId, activity);
|
||||
}
|
||||
|
||||
protected override async Task OnInstallationUpdateAddAsync(ITurnContext<IInstallationUpdateActivity> turnContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var conversationId = turnContext.Activity.Conversation.Id;
|
||||
var serviceUrl = turnContext.Activity.ServiceUrl;
|
||||
var teamId = turnContext.Activity.TeamsGetTeamInfo().AadGroupId;
|
||||
var tenantId = turnContext.Activity.Conversation.TenantId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(conversationId) &&
|
||||
!string.IsNullOrWhiteSpace(serviceUrl) &&
|
||||
Uri.TryCreate(serviceUrl, UriKind.Absolute, out var parsedUri) &&
|
||||
!string.IsNullOrWhiteSpace(teamId) &&
|
||||
!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
await HandleIncomingAppInstallAsync(
|
||||
conversationId: conversationId,
|
||||
serviceUrl: parsedUri,
|
||||
teamId: teamId,
|
||||
tenantId: tenantId
|
||||
);
|
||||
}
|
||||
|
||||
await base.OnInstallationUpdateAddAsync(turnContext, cancellationToken);
|
||||
}
|
||||
|
||||
internal async Task HandleIncomingAppInstallAsync(
|
||||
string conversationId,
|
||||
Uri serviceUrl,
|
||||
string teamId,
|
||||
string tenantId)
|
||||
{
|
||||
var integration = await integrationRepository.GetByTeamsConfigurationTenantIdTeamId(
|
||||
tenantId: tenantId,
|
||||
teamId: teamId);
|
||||
|
||||
if (integration?.Configuration is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var teamsConfig = JsonSerializer.Deserialize<TeamsIntegration>(integration.Configuration);
|
||||
if (teamsConfig is null || teamsConfig.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
integration.Configuration = JsonSerializer.Serialize(teamsConfig with
|
||||
{
|
||||
ChannelId = conversationId,
|
||||
ServiceUrl = serviceUrl
|
||||
});
|
||||
|
||||
await integrationRepository.UpsertAsync(integration);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class WebhookIntegrationHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
TimeProvider timeProvider)
|
||||
: IntegrationHandlerBase<WebhookIntegrationConfigurationDetails>
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
public const string HttpClientName = "WebhookIntegrationHandlerHttpClient";
|
||||
|
||||
public override async Task<IntegrationHandlerResult> HandleAsync(
|
||||
IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri);
|
||||
request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
|
||||
if (!string.IsNullOrEmpty(message.Configuration.Scheme))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
scheme: message.Configuration.Scheme,
|
||||
parameter: message.Configuration.Token
|
||||
);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
return ResultFromHttpResponse(response, message, timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Bit.Core.Models.Slack;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
public class NoopSlackService : ISlackService
|
||||
{
|
||||
public Task<string> GetChannelIdAsync(string token, string channelName)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
public Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
|
||||
{
|
||||
return Task.FromResult(new List<string>());
|
||||
}
|
||||
|
||||
public Task<string> GetDmChannelByEmailAsync(string token, string email)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
public string GetRedirectUrl(string callbackUrl, string state)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
|
||||
string channelId)
|
||||
{
|
||||
return Task.FromResult<SlackSendMessageResponse?>(null);
|
||||
}
|
||||
|
||||
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
public class NoopTeamsService : ITeamsService
|
||||
{
|
||||
public string GetRedirectUrl(string callbackUrl, string state)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TeamInfo>>(Array.Empty<TeamInfo>());
|
||||
}
|
||||
|
||||
public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user