From 75a2da3c4bd4debb45be28b7d50743a3726c25c6 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 5 May 2025 08:04:59 -0400 Subject: [PATCH] [PM-17562] Add support for extended properties on event integrations (#5755) * [PM-17562] Add support for extended properties on event integrations * Clean up IntegrationEventHandlerBase * Respond to PR feedback --- .../IntegrationTemplateContext.cs | 37 +++ .../IntegrationEventHandlerBase.cs | 66 ++++++ .../Implementations/SlackEventHandler.cs | 47 ++-- .../Implementations/WebhookEventHandler.cs | 47 ++-- .../Utilities/IntegrationTemplateProcessor.cs | 35 ++- .../IntegrationEventHandlerBaseTests.cs | 219 ++++++++++++++++++ .../IntegrationTemplateProcessorTests.cs | 57 +++++ 7 files changed, 445 insertions(+), 63 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs create mode 100644 test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs new file mode 100644 index 0000000000..18aa3b7681 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; + +#nullable enable + +namespace Bit.Core.Models.Data.Integrations; + +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 User? User { get; set; } + public string? UserName => User?.Name; + public string? UserEmail => User?.Email; + + public User? ActingUser { get; set; } + public string? ActingUserName => ActingUser?.Name; + public string? ActingUserEmail => ActingUser?.Email; + + public Organization? Organization { get; set; } + public string? OrganizationName => Organization?.DisplayName(); +} diff --git a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs new file mode 100644 index 0000000000..d8e521de97 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Integrations; +using Bit.Core.Repositories; + +namespace Bit.Core.Services; + +public abstract class IntegrationEventHandlerBase( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IEventMessageHandler +{ + public async Task HandleEventAsync(EventMessage eventMessage) + { + var organizationId = eventMessage.OrganizationId ?? Guid.Empty; + var configurations = await configurationRepository.GetConfigurationDetailsAsync( + organizationId, + GetIntegrationType(), + eventMessage.Type); + + foreach (var configuration in configurations) + { + var context = await BuildContextAsync(eventMessage, configuration.Template); + var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); + + await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); + } + } + + public async Task HandleManyEventsAsync(IEnumerable eventMessages) + { + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } + } + + private async Task BuildContextAsync(EventMessage eventMessage, string template) + { + var context = new IntegrationTemplateContext(eventMessage); + + if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) + { + context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) + { + context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) + { + context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); + } + + return context; + } + + protected abstract IntegrationType GetIntegrationType(); + + protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs index c81914b708..3ddecc67f4 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs @@ -1,46 +1,35 @@ using System.Text.Json; -using Bit.Core.AdminConsole.Utilities; +using System.Text.Json.Nodes; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; +#nullable enable + namespace Bit.Core.Services; public class SlackEventHandler( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, IOrganizationIntegrationConfigurationRepository configurationRepository, ISlackService slackService) - : IEventMessageHandler + : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) { - public async Task HandleEventAsync(EventMessage eventMessage) + protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; + + protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, + string renderedTemplate) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - IntegrationType.Slack, - eventMessage.Type); - - foreach (var configuration in configurations) + var config = mergedConfiguration.Deserialize(); + if (config is null) { - var config = configuration.MergedConfiguration.Deserialize(); - if (config is null) - { - continue; - } - - await slackService.SendSlackMessageByChannelIdAsync( - config.token, - IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), - config.channelId - ); + return; } - } - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } + await slackService.SendSlackMessageByChannelIdAsync( + config.token, + renderedTemplate, + config.channelId + ); } } diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs index 1c3b279ee5..ec6924bb3e 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs @@ -1,8 +1,7 @@ using System.Text; using System.Text.Json; -using Bit.Core.AdminConsole.Utilities; +using System.Text.Json.Nodes; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Integrations; using Bit.Core.Repositories; @@ -12,46 +11,28 @@ namespace Bit.Core.Services; public class WebhookEventHandler( IHttpClientFactory httpClientFactory, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, IOrganizationIntegrationConfigurationRepository configurationRepository) - : IEventMessageHandler + : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); public const string HttpClientName = "WebhookEventHandlerHttpClient"; - public async Task HandleEventAsync(EventMessage eventMessage) + protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; + + protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, + string renderedTemplate) { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - IntegrationType.Webhook, - eventMessage.Type); - - foreach (var configuration in configurations) + var config = mergedConfiguration.Deserialize(); + if (config is null || string.IsNullOrEmpty(config.url)) { - var config = configuration.MergedConfiguration.Deserialize(); - if (config is null || string.IsNullOrEmpty(config.url)) - { - continue; - } - - var content = new StringContent( - IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage), - Encoding.UTF8, - "application/json" - ); - var response = await _httpClient.PostAsync( - config.url, - content); - response.EnsureSuccessStatusCode(); + return; } - } - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } + var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync(config.url, content); + response.EnsureSuccessStatusCode(); } } diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index 178c0348d9..4fb5c15e63 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -10,8 +10,9 @@ public static partial class IntegrationTemplateProcessor public static string ReplaceTokens(string template, object values) { if (string.IsNullOrEmpty(template) || values == null) + { return template; - + } var type = values.GetType(); return TokenRegex().Replace(template, match => { @@ -20,4 +21,36 @@ public static partial class IntegrationTemplateProcessor return property?.GetValue(values)?.ToString() ?? match.Value; }); } + + public static bool TemplateRequiresUser(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#UserName#", StringComparison.Ordinal) + || template.Contains("#UserEmail#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresActingUser(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#ActingUserName#", StringComparison.Ordinal) + || template.Contains("#ActingUserEmail#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresOrganization(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#OrganizationName#", StringComparison.Ordinal); + } } diff --git a/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs new file mode 100644 index 0000000000..e1a2fbff68 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/IntegrationEventHandlerBaseTests.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class IntegrationEventHandlerBaseEventHandlerTests +{ + private const string _templateBase = "Date: #Date#, Type: #Type#, UserId: #UserId#"; + 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 SutProvider GetSutProvider( + List configurations) + { + var configurationRepository = Substitute.For(); + configurationRepository.GetConfigurationDetailsAsync(Arg.Any(), + IntegrationType.Webhook, Arg.Any()).Returns(configurations); + + return new SutProvider() + .SetDependency(configurationRepository) + .Create(); + } + + private static List NoConfigurations() + { + return []; + } + + private static List OneConfiguration(string template) + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config.Template = template; + + return [config]; + } + + private static List TwoConfigurations(string template) + { + var config = Substitute.For(); + config.Configuration = null; + config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config.Template = template; + var config2 = Substitute.For(); + config2.Configuration = null; + config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url }); + config2.Template = template; + + return [config, config2]; + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + Assert.Empty(sutProvider.Sut.CapturedCalls); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; + + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser)); + var user = Substitute.For(); + user.Email = "test@example.com"; + user.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"{user.Name}, {user.Email}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.ActingUserId ?? Guid.Empty); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); + var organization = Substitute.For(); + organization.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(organization); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"Org: {organization.Name}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser)); + var user = Substitute.For(); + user.Email = "test@example.com"; + user.Name = "Test"; + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + await sutProvider.Sut.HandleEventAsync(eventMessage); + + Assert.Single(sutProvider.Sut.CapturedCalls); + + var expectedTemplate = $"{user.Name}, {user.Email}"; + Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1).GetByIdAsync(eventMessage.UserId ?? Guid.Empty); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List eventMessages) + { + var sutProvider = GetSutProvider(NoConfigurations()); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + Assert.Empty(sutProvider.Sut.CapturedCalls); + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(List eventMessages) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + Assert.Equal(eventMessages.Count, sutProvider.Sut.CapturedCalls.Count); + var index = 0; + foreach (var call in sutProvider.Sut.CapturedCalls) + { + var expected = eventMessages[index]; + var expectedTemplate = $"Date: {expected.Date}, Type: {expected.Type}, UserId: {expected.UserId}"; + + Assert.Equal(expectedTemplate, call.RenderedTemplate); + index++; + } + } + + [Theory, BitAutoData] + public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_CallsProcessEventIntegrationAsyncMultipleTimes( + List eventMessages) + { + var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); + + await sutProvider.Sut.HandleManyEventsAsync(eventMessages); + + Assert.Equal(eventMessages.Count * 2, sutProvider.Sut.CapturedCalls.Count); + + var capturedCalls = sutProvider.Sut.CapturedCalls.GetEnumerator(); + foreach (var eventMessage in eventMessages) + { + var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"; + + Assert.True(capturedCalls.MoveNext()); + var call = capturedCalls.Current; + Assert.Equal(expectedTemplate, call.RenderedTemplate); + + Assert.True(capturedCalls.MoveNext()); + call = capturedCalls.Current; + Assert.Equal(expectedTemplate, call.RenderedTemplate); + } + } + + private class TestIntegrationEventHandlerBase : IntegrationEventHandlerBase + { + public TestIntegrationEventHandlerBase(IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : base(userRepository, organizationRepository, configurationRepository) + { } + + public List<(JsonObject MergedConfiguration, string RenderedTemplate)> CapturedCalls { get; } = new(); + + protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; + + protected override Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate) + { + CapturedCalls.Add((mergedConfiguration, renderedTemplate)); + return Task.CompletedTask; + } + } +} diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs index 9ab3b592cb..d117b5e999 100644 --- a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -89,4 +89,61 @@ public class IntegrationTemplateProcessorTests Assert.Equal(expected, result); } + + [Theory] + [InlineData("User name is #UserName#")] + [InlineData("Email: #UserEmail#")] + public void TemplateRequiresUser_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresUser(template); + Assert.True(result); + } + + [Theory] + [InlineData("#UserId#")] // This is on the base class, not fetched, so should be false + [InlineData("No User Tokens")] + [InlineData("")] + public void TemplateRequiresUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresUser(template); + Assert.False(result); + } + + [Theory] + [InlineData("Acting user is #ActingUserName#")] + [InlineData("Acting user's email is #ActingUserEmail#")] + public void TemplateRequiresActingUser_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template); + Assert.True(result); + } + + [Theory] + [InlineData("No ActiveUser tokens")] + [InlineData("#ActiveUserId#")] // This is on the base class, not fetched, so should be false + [InlineData("")] + public void TemplateRequiresActingUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template); + Assert.False(result); + } + + [Theory] + [InlineData("Organization: #OrganizationName#")] + [InlineData("Welcome to #OrganizationName#")] + public void TemplateRequiresOrganization_ContainingKeys_ReturnsTrue(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template); + Assert.True(result); + } + + [Theory] + [InlineData("No organization tokens")] + [InlineData("#OrganizationId#")] // This is on the base class, not fetched, so should be false + [InlineData("")] + public void TemplateRequiresOrganization_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template) + { + var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template); + Assert.False(result); + } }