diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index b107bc6190..dcf48b7a8c 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -31,6 +31,9 @@ }, { "Name": "events-webhook-subscription" + }, + { + "Name": "events-hec-subscription" } ] }, @@ -64,6 +67,20 @@ } } ] + }, + { + "Name": "integration-hec-subscription", + "Rules": [ + { + "Name": "hec-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "hec" + } + } + } + ] } ] } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 85d46c791b..975a7745a1 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -27,6 +27,8 @@ public class OrganizationIntegrationConfigurationRequestModel return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid(); case IntegrationType.Webhook: return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid(); + case IntegrationType.Hec: + return !string.IsNullOrWhiteSpace(Template) && Configuration is null; default: return false; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 1a5c110254..4b8a24fbce 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; #nullable enable @@ -39,10 +41,18 @@ public class OrganizationIntegrationRequestModel : IValidatableObject yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) }); break; case IntegrationType.Webhook: - if (Configuration is not null) + if (!string.IsNullOrWhiteSpace(Configuration) && !IsIntegrationValid()) { yield return new ValidationResult( - "Webhook integrations must not include configuration.", + "Webhook integrations must include valid configuration.", + new[] { nameof(Configuration) }); + } + break; + case IntegrationType.Hec: + if (!IsIntegrationValid()) + { + yield return new ValidationResult( + "HEC integrations must include valid configuration.", new[] { nameof(Configuration) }); } break; @@ -53,4 +63,22 @@ public class OrganizationIntegrationRequestModel : IValidatableObject break; } } + + private bool IsIntegrationValid() + { + if (string.IsNullOrWhiteSpace(Configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(Configuration); + return config is not null; + } + catch + { + return false; + } + } } diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 5edd54df23..58e55193dc 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -6,6 +6,7 @@ public enum IntegrationType : int Scim = 2, Slack = 3, Webhook = 4, + Hec = 5 } public static class IntegrationTypeExtensions @@ -18,6 +19,8 @@ public static class IntegrationTypeExtensions return "slack"; case IntegrationType.Webhook: return "webhook"; + case IntegrationType.Hec: + return "hec"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs new file mode 100644 index 0000000000..7b7f025975 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record HecIntegration(string Scheme, string Token, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs new file mode 100644 index 0000000000..84b4b97857 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs index ff28edc301..2f5e8d29c1 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs @@ -2,4 +2,4 @@ namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; -public record WebhookIntegrationConfiguration(string Url, string? Scheme = null, string? Token = null); +public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs index e0ed5dfcfa..4fa1a67c8e 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs @@ -2,4 +2,4 @@ namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; -public record WebhookIntegrationConfigurationDetails(string Url, string? Scheme = null, string? Token = null); +public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 54b36d2595..1e4c4b490d 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -197,22 +197,37 @@ interface and therefore can also handle directly all the message publishing func 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 and webhooks (as mentioned above). +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 Slack, it consists of the token: `{ "token": "xoxb-token-from-slack" }` - - For webhooks, it is `null`. However, even though there is no configuration, an organization must - have a webhook `OrganizationIntegration` to enable configuration via `OrganizationIntegrationConfiguration`. + - For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`. + - For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level, + but the configuration level takes precedence. However, even though it is optional, an organization must + have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration + via `OrganizationIntegrationConfiguration`. + - For HEC, it consists of the scheme, token, and URI: + +```json + { + "Scheme": "Bearer", + "Token": "Auth-token-from-HEC-service", + "Uri": "https://example.com/api" + } +``` ### `OrganizationIntegrationConfiguration` - This contains the configurations specific to each `EventType` for the integration. - `Configuration` contains the event-specific configuration. - For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }` - - For Webhook, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` + - For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` + - Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication. + - As stated above, all of this information can be specified here or at the `OrganizationIntegration` + level, but any properties declared here will take precedence over the ones above. + - for HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level. - `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 @@ -225,6 +240,8 @@ Currently, there are integrations / handlers for Slack and webhooks (as mentione - 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. diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index b66df59a69..99cad65efa 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -6,8 +6,6 @@ using System.Net.Http.Headers; using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -#nullable enable - namespace Bit.Core.Services; public class WebhookIntegrationHandler( @@ -21,7 +19,7 @@ public class WebhookIntegrationHandler( public override async Task HandleAsync(IntegrationMessage message) { - var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Url); + 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)) { diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index aab4e448e5..dceeea85f4 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Text.Json; using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -19,8 +20,15 @@ public static partial class IntegrationTemplateProcessor return TokenRegex().Replace(template, match => { var propertyName = match.Groups[1].Value; - var property = type.GetProperty(propertyName); - return property?.GetValue(values)?.ToString() ?? match.Value; + if (propertyName == "EventMessage") + { + return JsonSerializer.Serialize(values); + } + else + { + var property = type.GetProperty(propertyName); + return property?.GetValue(values)?.ToString() ?? match.Value; + } }); } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 7a794ec3f6..ba6d4e692e 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -297,6 +297,8 @@ public class GlobalSettings : IGlobalSettings public virtual string SlackIntegrationSubscriptionName { get; set; } = "integration-slack-subscription"; public virtual string WebhookEventSubscriptionName { get; set; } = "events-webhook-subscription"; 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 string ConnectionString { @@ -336,6 +338,9 @@ public class GlobalSettings : IGlobalSettings public virtual string WebhookEventsQueueName { get; set; } = "events-webhook-queue"; public virtual string WebhookIntegrationQueueName { get; set; } = "integration-webhook-queue"; public virtual string WebhookIntegrationRetryQueueName { get; set; } = "integration-webhook-retry-queue"; + 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 string HostName { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 83015354bb..0fb09544c7 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -664,6 +664,13 @@ public static class ServiceCollectionExtensions integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.WebhookIntegrationSubscriptionName, integrationType: IntegrationType.Webhook, globalSettings: globalSettings); + + services.AddAzureServiceBusIntegration( + eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.HecEventSubscriptionName, + integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.HecIntegrationSubscriptionName, + integrationType: IntegrationType.Hec, + globalSettings: globalSettings); + return services; } @@ -750,6 +757,13 @@ public static class ServiceCollectionExtensions globalSettings.EventLogging.RabbitMq.MaxRetries, IntegrationType.Webhook); + services.AddRabbitMqIntegration( + globalSettings.EventLogging.RabbitMq.HecEventsQueueName, + globalSettings.EventLogging.RabbitMq.HecIntegrationQueueName, + globalSettings.EventLogging.RabbitMq.HecIntegrationRetryQueueName, + globalSettings.EventLogging.RabbitMq.MaxRetries, + IntegrationType.Hec); + return services; } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs index 77283475cf..af1e002f81 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -188,7 +188,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; @@ -225,7 +225,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; @@ -387,7 +387,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = null; @@ -473,7 +473,7 @@ public class OrganizationIntegrationsConfigurationControllerTests organizationIntegration.OrganizationId = organizationId; organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; @@ -515,7 +515,7 @@ public class OrganizationIntegrationsConfigurationControllerTests organizationIntegration.OrganizationId = organizationId; organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; @@ -555,7 +555,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs index 5958aa06aa..be88958968 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -17,13 +17,13 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.False(model.IsValidForType(IntegrationType.CloudBillingSync)); + Assert.False(condition: model.IsValidForType(IntegrationType.CloudBillingSync)); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] + [InlineData(data: null)] + [InlineData(data: "")] + [InlineData(data: " ")] public void IsValidForType_EmptyConfiguration_ReturnsFalse(string? config) { var model = new OrganizationIntegrationConfigurationRequestModel @@ -32,25 +32,55 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - var result = model.IsValidForType(IntegrationType.Slack); - - Assert.False(result); + Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); + Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] + [InlineData(data: "")] + [InlineData(data: " ")] + public void IsValidForType_EmptyNonNullHecConfiguration_ReturnsFalse(string? config) + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + } + + [Fact] + public void IsValidForType_NullHecConfiguration_ReturnsTrue() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = null, + Template = "template" + }; + + Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); + } + + [Theory] + [InlineData(data: null)] + [InlineData(data: "")] + [InlineData(data: " ")] public void IsValidForType_EmptyTemplate_ReturnsFalse(string? template) { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com", "Bearer", "AUTH-TOKEN")); + var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( + Uri: new Uri("https://localhost"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, Template = template }; - Assert.False(model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); + Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); } [Fact] @@ -62,7 +92,9 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.False(model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); + Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); } [Fact] @@ -74,13 +106,13 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.False(model.IsValidForType(IntegrationType.Scim)); + Assert.False(condition: model.IsValidForType(IntegrationType.Scim)); } [Fact] public void IsValidForType_ValidSlackConfiguration_ReturnsTrue() { - var config = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")); + var config = JsonSerializer.Serialize(value: new SlackIntegrationConfiguration(ChannelId: "C12345")); var model = new OrganizationIntegrationConfigurationRequestModel { @@ -88,33 +120,36 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.True(model.IsValidForType(IntegrationType.Slack)); + Assert.True(condition: model.IsValidForType(IntegrationType.Slack)); } [Fact] public void IsValidForType_ValidNoAuthWebhookConfiguration_ReturnsTrue() { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com")); + var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"))); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, Template = "template" }; - Assert.True(model.IsValidForType(IntegrationType.Webhook)); + Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); } [Fact] public void IsValidForType_ValidWebhookConfiguration_ReturnsTrue() { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com", "Bearer", "AUTH-TOKEN")); + var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( + Uri: new Uri("https://localhost"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, Template = "template" }; - Assert.True(model.IsValidForType(IntegrationType.Webhook)); + Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); } [Fact] @@ -128,6 +163,6 @@ public class OrganizationIntegrationConfigurationRequestModelTests var unknownType = (IntegrationType)999; - Assert.False(model.IsValidForType(unknownType)); + Assert.False(condition: model.IsValidForType(unknownType)); } } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index fc9b399abd..716c90e797 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Xunit; @@ -70,7 +72,7 @@ public class OrganizationIntegrationRequestModelTests } [Fact] - public void Validate_Webhook_WithConfiguration_ReturnsConfigurationError() + public void Validate_Webhook_WithInvalidConfiguration_ReturnsConfigurationError() { var model = new OrganizationIntegrationRequestModel { @@ -82,7 +84,67 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must not include configuration", results[0].ErrorMessage); + Assert.Contains("must include valid configuration", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Webhook_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Webhook, + Configuration = JsonSerializer.Serialize(new WebhookIntegration(new Uri("https://example.com"))) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Validate_Hec_WithNullConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + 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_Hec_WithInvalidConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + 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_Hec_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = JsonSerializer.Serialize(new HecIntegration("Bearer", Token: "Token", Uri: new Uri("http://localhost"))) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); } [Fact] diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs index a68bfd4fcb..edd5cd488f 100644 --- a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs @@ -14,7 +14,7 @@ public class IntegrationMessageTests { var message = new IntegrationMessage { - Configuration = new WebhookIntegrationConfigurationDetails("https://localhost", "Bearer", "AUTH-TOKEN"), + Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"), MessageId = _messageId, RetryCount = 2, RenderedTemplate = string.Empty, @@ -34,7 +34,7 @@ public class IntegrationMessageTests { var message = new IntegrationMessage { - Configuration = new WebhookIntegrationConfigurationDetails("https://localhost", "Bearer", "AUTH-TOKEN"), + Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"), MessageId = _messageId, RenderedTemplate = "This is the message", IntegrationType = IntegrationType.Webhook, diff --git a/test/Core.Test/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetailsTests.cs b/test/Core.Test/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetailsTests.cs index 99a11903b4..4b8cd4f47c 100644 --- a/test/Core.Test/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetailsTests.cs +++ b/test/Core.Test/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetailsTests.cs @@ -22,6 +22,22 @@ public class OrganizationIntegrationConfigurationDetailsTests Assert.Equal(expected, result.ToJsonString()); } + [Fact] + public void MergedConfiguration_WithSameKeyIndConfigAndIntegration_GivesPrecedenceToConfiguration() + { + var config = new { config = "A new config value" }; + var integration = new { config = "An integration value" }; + var expectedObj = new { config = "A new config value" }; + var expected = JsonSerializer.Serialize(expectedObj); + + var sut = new OrganizationIntegrationConfigurationDetails(); + sut.Configuration = JsonSerializer.Serialize(config); + sut.IntegrationConfiguration = JsonSerializer.Serialize(integration); + + var result = sut.MergedConfiguration; + Assert.Equal(expected, result.ToJsonString()); + } + [Fact] public void MergedConfiguration_WithInvalidJsonConfigAndIntegration_ReturnsEmptyJson() { diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index 8dcc49a8f1..f4bfb573f2 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -22,8 +22,8 @@ public class EventIntegrationHandlerTests private const string _templateWithOrganization = "Org: #OrganizationName#"; private const string _templateWithUser = "#UserName#, #UserEmail#"; private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#"; - private const string _url = "https://localhost"; - private const string _url2 = "https://example.com"; + private static readonly Uri _uri = new Uri("https://localhost"); + private static readonly Uri _uri2 = new Uri("https://example.com"); private readonly IEventIntegrationPublisher _eventIntegrationPublisher = Substitute.For(); private SutProvider> GetSutProvider( @@ -46,7 +46,7 @@ public class EventIntegrationHandlerTests { IntegrationType = IntegrationType.Webhook, MessageId = "TestMessageId", - Configuration = new WebhookIntegrationConfigurationDetails(_url), + Configuration = new WebhookIntegrationConfigurationDetails(_uri), RenderedTemplate = template, RetryCount = 0, DelayUntilDate = null @@ -62,7 +62,7 @@ public class EventIntegrationHandlerTests { var config = Substitute.For(); config.Configuration = null; - config.IntegrationConfiguration = JsonSerializer.Serialize(new { Url = _url }); + config.IntegrationConfiguration = JsonSerializer.Serialize(new { Uri = _uri }); config.Template = template; return [config]; @@ -72,11 +72,11 @@ public class EventIntegrationHandlerTests { var config = Substitute.For(); config.Configuration = null; - config.IntegrationConfiguration = JsonSerializer.Serialize(new { Url = _url }); + config.IntegrationConfiguration = JsonSerializer.Serialize(new { Uri = _uri }); config.Template = template; var config2 = Substitute.For(); config2.Configuration = null; - config2.IntegrationConfiguration = JsonSerializer.Serialize(new { Url = _url2 }); + config2.IntegrationConfiguration = JsonSerializer.Serialize(new { Uri = _uri2 }); config2.Template = template; return [config, config2]; @@ -212,7 +212,7 @@ public class EventIntegrationHandlerTests await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); - expectedMessage.Configuration = new WebhookIntegrationConfigurationDetails(_url2); + expectedMessage.Configuration = new WebhookIntegrationConfigurationDetails(_uri2); await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); } diff --git a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs index b4a384d798..aa93567538 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs @@ -14,7 +14,7 @@ public class IntegrationHandlerTests var sut = new TestIntegrationHandler(); var expected = new IntegrationMessage() { - Configuration = new WebhookIntegrationConfigurationDetails("https://localhost", "Bearer", "AUTH-TOKEN"), + Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"), MessageId = "TestMessageId", IntegrationType = IntegrationType.Webhook, RenderedTemplate = "Template", diff --git a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs index 9a03fb28f0..bf4283243c 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs @@ -19,7 +19,7 @@ public class WebhookIntegrationHandlerTests private readonly HttpClient _httpClient; private const string _scheme = "Bearer"; private const string _token = "AUTH_TOKEN"; - private const string _webhookUrl = "http://localhost/test/event"; + private static readonly Uri _webhookUri = new Uri("https://localhost"); public WebhookIntegrationHandlerTests() { @@ -45,7 +45,7 @@ public class WebhookIntegrationHandlerTests public async Task HandleAsync_SuccessfulRequestWithoutAuth_ReturnsSuccess(IntegrationMessage message) { var sutProvider = GetSutProvider(); - message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl); + message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUri); var result = await sutProvider.Sut.HandleAsync(message); @@ -63,7 +63,7 @@ public class WebhookIntegrationHandlerTests Assert.Equal(HttpMethod.Post, request.Method); Assert.Null(request.Headers.Authorization); - Assert.Equal(_webhookUrl, request.RequestUri.ToString()); + Assert.Equal(_webhookUri, request.RequestUri); AssertHelper.AssertPropertyEqual(message.RenderedTemplate, returned); } @@ -71,7 +71,7 @@ public class WebhookIntegrationHandlerTests public async Task HandleAsync_SuccessfulRequestWithAuthorizationHeader_ReturnsSuccess(IntegrationMessage message) { var sutProvider = GetSutProvider(); - message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl, _scheme, _token); + message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUri, _scheme, _token); var result = await sutProvider.Sut.HandleAsync(message); @@ -89,7 +89,7 @@ public class WebhookIntegrationHandlerTests Assert.Equal(HttpMethod.Post, request.Method); Assert.Equal(new AuthenticationHeaderValue(_scheme, _token), request.Headers.Authorization); - Assert.Equal(_webhookUrl, request.RequestUri.ToString()); + Assert.Equal(_webhookUri, request.RequestUri); AssertHelper.AssertPropertyEqual(message.RenderedTemplate, returned); } @@ -101,7 +101,7 @@ public class WebhookIntegrationHandlerTests var retryAfter = now.AddSeconds(60); sutProvider.GetDependency().SetUtcNow(now); - message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl, _scheme, _token); + message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUri, _scheme, _token); _handler.Fallback .WithStatusCode(HttpStatusCode.TooManyRequests) @@ -124,7 +124,7 @@ public class WebhookIntegrationHandlerTests var sutProvider = GetSutProvider(); var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc); var retryAfter = now.AddSeconds(60); - message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl, _scheme, _token); + message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUri, _scheme, _token); _handler.Fallback .WithStatusCode(HttpStatusCode.TooManyRequests) @@ -145,7 +145,7 @@ public class WebhookIntegrationHandlerTests public async Task HandleAsync_InternalServerError_ReturnsFailureSetsRetryable(IntegrationMessage message) { var sutProvider = GetSutProvider(); - message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl, _scheme, _token); + message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUri, _scheme, _token); _handler.Fallback .WithStatusCode(HttpStatusCode.InternalServerError) @@ -164,7 +164,7 @@ public class WebhookIntegrationHandlerTests public async Task HandleAsync_UnexpectedRedirect_ReturnsFailureNotRetryable(IntegrationMessage message) { var sutProvider = GetSutProvider(); - message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUrl, _scheme, _token); + message.Configuration = new WebhookIntegrationConfigurationDetails(_webhookUri, _scheme, _token); _handler.Fallback .WithStatusCode(HttpStatusCode.TemporaryRedirect) diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs index 155eceeb25..105b65d0da 100644 --- a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Text.Json; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Models.Data; using Bit.Test.Common.AutoFixture.Attributes; @@ -39,6 +40,16 @@ public class IntegrationTemplateProcessorTests Assert.Equal(expected, result); } + [Theory, BitAutoData] + public void ReplaceTokens_WithEventMessageToken_ReplacesWithSerializedJson(EventMessage eventMessage) + { + var template = "#EventMessage#"; + var expected = $"{JsonSerializer.Serialize(eventMessage)}"; + var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); + + Assert.Equal(expected, result); + } + [Theory, BitAutoData] public void ReplaceTokens_WithNullProperty_LeavesTokenUnchanged(EventMessage eventMessage) {