1
0
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:
Brant DeBow
2025-12-30 10:59:19 -05:00
committed by GitHub
parent 9a340c0fdd
commit 86a68ab637
158 changed files with 487 additions and 472 deletions

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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}");
}
}
}

View File

@@ -1,10 +0,0 @@
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public enum OrganizationIntegrationStatus : int
{
NotApplicable,
Invalid,
Initiated,
InProgress,
Completed
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
));
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
));
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record DatadogIntegration(string ApiKey, Uri Uri);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri);

View File

@@ -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;
}
}

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null);

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -1,9 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public enum IntegrationFilterOperation
{
Equals = 0,
NotEquals = 1,
In = 2,
NotIn = 3
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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];
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegration(string Token);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfiguration(string ChannelId);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record SlackIntegrationConfigurationDetails(string ChannelId, string Token);

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);

View File

@@ -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;
}
}

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null);

View File

@@ -1,3 +0,0 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null);

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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"
);
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
};
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 users 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);
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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
)
);
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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[]");
}
}

View File

@@ -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.

View File

@@ -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}");
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}