mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
Add Datadog integration (#6289)
* Event integration updates and cleanups * Add Datadog integration * Update README to include link to Datadog PR * Move doc update into the Datadog PR; Fix empty message on ArgumentException * Adjust exception message Co-authored-by: Matt Bishop <mbishop@bitwarden.com> * Removed unnecessary nullable enable; Moved Docs link to PR into this PR * Remove unnecessary nullable enable calls --------- Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"Name": "events-hec-subscription"
|
||||
},
|
||||
{
|
||||
"Name": "events-datadog-subscription"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -81,6 +84,20 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "integration-datadog-subscription",
|
||||
"Rules": [
|
||||
{
|
||||
"Name": "datadog-integration-filter",
|
||||
"Properties": {
|
||||
"FilterType": "Correlation",
|
||||
"CorrelationFilter": {
|
||||
"Label": "datadog"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
@@ -36,6 +34,10 @@ public class OrganizationIntegrationConfigurationRequestModel
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
Configuration is null &&
|
||||
IsFiltersValid();
|
||||
case IntegrationType.Datadog:
|
||||
return !string.IsNullOrWhiteSpace(Template) &&
|
||||
Configuration is null &&
|
||||
IsFiltersValid();
|
||||
default:
|
||||
return false;
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
@@ -60,6 +58,14 @@ public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
new[] { nameof(Configuration) });
|
||||
}
|
||||
break;
|
||||
case IntegrationType.Datadog:
|
||||
if (!IsIntegrationValid<DatadogIntegration>())
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Datadog integrations must include valid configuration.",
|
||||
new[] { nameof(Configuration) });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
yield return new ValidationResult(
|
||||
$"Integration type '{Type}' is not recognized.",
|
||||
|
||||
@@ -6,7 +6,8 @@ public enum IntegrationType : int
|
||||
Scim = 2,
|
||||
Slack = 3,
|
||||
Webhook = 4,
|
||||
Hec = 5
|
||||
Hec = 5,
|
||||
Datadog = 6
|
||||
}
|
||||
|
||||
public static class IntegrationTypeExtensions
|
||||
@@ -21,6 +22,8 @@ public static class IntegrationTypeExtensions
|
||||
return "webhook";
|
||||
case IntegrationType.Hec:
|
||||
return "hec";
|
||||
case IntegrationType.Datadog:
|
||||
return "datadog";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegration(string ApiKey, Uri Uri);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri);
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,8 @@ A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the bac
|
||||
# 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".
|
||||
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
|
||||
|
||||
|
||||
@@ -304,6 +304,8 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual string WebhookIntegrationSubscriptionName { get; set; } = "integration-webhook-subscription";
|
||||
public virtual string HecEventSubscriptionName { get; set; } = "events-hec-subscription";
|
||||
public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription";
|
||||
public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription";
|
||||
public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription";
|
||||
|
||||
public string ConnectionString
|
||||
{
|
||||
@@ -345,6 +347,9 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual string HecEventsQueueName { get; set; } = "events-hec-queue";
|
||||
public virtual string HecIntegrationQueueName { get; set; } = "integration-hec-queue";
|
||||
public virtual string HecIntegrationRetryQueueName { get; set; } = "integration-hec-retry-queue";
|
||||
public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue";
|
||||
public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue";
|
||||
public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue";
|
||||
|
||||
public string HostName
|
||||
{
|
||||
|
||||
@@ -881,15 +881,18 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSlackService(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>();
|
||||
|
||||
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);
|
||||
|
||||
if (IsRabbitMqEnabled(globalSettings))
|
||||
{
|
||||
@@ -906,6 +909,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddRabbitMqIntegration<SlackIntegrationConfigurationDetails, SlackListenerConfiguration>(slackConfiguration);
|
||||
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
|
||||
services.AddRabbitMqIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
|
||||
services.AddRabbitMqIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
|
||||
}
|
||||
|
||||
if (IsAzureServiceBusEnabled(globalSettings))
|
||||
@@ -923,6 +927,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAzureServiceBusIntegration<SlackIntegrationConfigurationDetails, SlackListenerConfiguration>(slackConfiguration);
|
||||
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, WebhookListenerConfiguration>(webhookConfiguration);
|
||||
services.AddAzureServiceBusIntegration<WebhookIntegrationConfigurationDetails, HecListenerConfiguration>(hecConfiguration);
|
||||
services.AddAzureServiceBusIntegration<DatadogIntegrationConfigurationDetails, DatadogListenerConfiguration>(datadogConfiguration);
|
||||
}
|
||||
|
||||
return services;
|
||||
|
||||
@@ -62,6 +62,32 @@ public class OrganizationIntegrationConfigurationRequestModelTests
|
||||
Assert.True(condition: model.IsValidForType(IntegrationType.Hec));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(data: "")]
|
||||
[InlineData(data: " ")]
|
||||
public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config)
|
||||
{
|
||||
var model = new OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
Configuration = config,
|
||||
Template = "template"
|
||||
};
|
||||
|
||||
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForType_NullDatadogConfiguration_ReturnsTrue()
|
||||
{
|
||||
var model = new OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
Configuration = null,
|
||||
Template = "template"
|
||||
};
|
||||
|
||||
Assert.True(condition: model.IsValidForType(IntegrationType.Datadog));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(data: null)]
|
||||
[InlineData(data: "")]
|
||||
|
||||
@@ -147,6 +147,54 @@ public class OrganizationIntegrationRequestModelTests
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Datadog_WithNullConfiguration_ReturnsError()
|
||||
{
|
||||
var model = new OrganizationIntegrationRequestModel
|
||||
{
|
||||
Type = IntegrationType.Datadog,
|
||||
Configuration = null
|
||||
};
|
||||
|
||||
var results = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Contains(nameof(model.Configuration), results[0].MemberNames);
|
||||
Assert.Contains("must include valid configuration", results[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Datadog_WithInvalidConfiguration_ReturnsError()
|
||||
{
|
||||
var model = new OrganizationIntegrationRequestModel
|
||||
{
|
||||
Type = IntegrationType.Datadog,
|
||||
Configuration = "Not valid"
|
||||
};
|
||||
|
||||
var results = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Contains(nameof(model.Configuration), results[0].MemberNames);
|
||||
Assert.Contains("must include valid configuration", results[0].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Datadog_WithValidConfiguration_ReturnsNoErrors()
|
||||
{
|
||||
var model = new OrganizationIntegrationRequestModel
|
||||
{
|
||||
Type = IntegrationType.Datadog,
|
||||
Configuration = JsonSerializer.Serialize(
|
||||
new DatadogIntegration(ApiKey: "API1234", Uri: new Uri("http://localhost"))
|
||||
)
|
||||
};
|
||||
|
||||
var results = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
Assert.Empty(results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnknownIntegrationType_ReturnsUnrecognizedError()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Net;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Bit.Test.Common.MockedHttpClient;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class DatadogIntegrationHandlerTests
|
||||
{
|
||||
private readonly MockedHttpMessageHandler _handler;
|
||||
private readonly HttpClient _httpClient;
|
||||
private const string _apiKey = "AUTH_TOKEN";
|
||||
private static readonly Uri _datadogUri = new Uri("https://localhost");
|
||||
|
||||
public DatadogIntegrationHandlerTests()
|
||||
{
|
||||
_handler = new MockedHttpMessageHandler();
|
||||
_handler.Fallback
|
||||
.WithStatusCode(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
|
||||
_httpClient = _handler.ToHttpClient();
|
||||
}
|
||||
|
||||
private SutProvider<DatadogIntegrationHandler> GetSutProvider()
|
||||
{
|
||||
var clientFactory = Substitute.For<IHttpClientFactory>();
|
||||
clientFactory.CreateClient(DatadogIntegrationHandler.HttpClientName).Returns(_httpClient);
|
||||
|
||||
return new SutProvider<DatadogIntegrationHandler>()
|
||||
.SetDependency(clientFactory)
|
||||
.WithFakeTimeProvider()
|
||||
.Create();
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<DatadogIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri);
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(result.Message, message);
|
||||
Assert.Empty(result.FailureReason);
|
||||
|
||||
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName))
|
||||
);
|
||||
|
||||
Assert.Single(_handler.CapturedRequests);
|
||||
var request = _handler.CapturedRequests[0];
|
||||
Assert.NotNull(request);
|
||||
Assert.NotNull(request.Content);
|
||||
var returned = await request.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpMethod.Post, request.Method);
|
||||
Assert.Equal(_apiKey, request.Headers.GetValues("DD-API-KEY").Single());
|
||||
Assert.Equal(_datadogUri, request.RequestUri);
|
||||
AssertHelper.AssertPropertyEqual(message.RenderedTemplate, returned);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_TooManyRequests_ReturnsFailureSetsDelayUntilDate(IntegrationMessage<DatadogIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc);
|
||||
var retryAfter = now.AddSeconds(60);
|
||||
|
||||
sutProvider.GetDependency<FakeTimeProvider>().SetUtcNow(now);
|
||||
message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri);
|
||||
|
||||
_handler.Fallback
|
||||
.WithStatusCode(HttpStatusCode.TooManyRequests)
|
||||
.WithHeader("Retry-After", "60")
|
||||
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
Assert.True(result.DelayUntilDate.HasValue);
|
||||
Assert.Equal(retryAfter, result.DelayUntilDate.Value);
|
||||
Assert.Equal("Too Many Requests", result.FailureReason);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_TooManyRequestsWithDate_ReturnsFailureSetsDelayUntilDate(IntegrationMessage<DatadogIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc);
|
||||
var retryAfter = now.AddSeconds(60);
|
||||
message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri);
|
||||
|
||||
_handler.Fallback
|
||||
.WithStatusCode(HttpStatusCode.TooManyRequests)
|
||||
.WithHeader("Retry-After", retryAfter.ToString("r"))
|
||||
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
Assert.True(result.DelayUntilDate.HasValue);
|
||||
Assert.Equal(retryAfter, result.DelayUntilDate.Value);
|
||||
Assert.Equal("Too Many Requests", result.FailureReason);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_InternalServerError_ReturnsFailureSetsRetryable(IntegrationMessage<DatadogIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri);
|
||||
|
||||
_handler.Fallback
|
||||
.WithStatusCode(HttpStatusCode.InternalServerError)
|
||||
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
Assert.False(result.DelayUntilDate.HasValue);
|
||||
Assert.Equal("Internal Server Error", result.FailureReason);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_UnexpectedRedirect_ReturnsFailureNotRetryable(IntegrationMessage<DatadogIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri);
|
||||
|
||||
_handler.Fallback
|
||||
.WithStatusCode(HttpStatusCode.TemporaryRedirect)
|
||||
.WithContent(new StringContent("<html><head><title>test</title></head><body>test</body></html>"));
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
Assert.Null(result.DelayUntilDate);
|
||||
Assert.Equal("Temporary Redirect", result.FailureReason);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user