1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-02 16:42:50 -05:00

[PM-17562] API For Organization Integrations/Configurations, Refactored Distributed Events, Slack Integration (#5654)

* [PM-17562] Slack Event Investigation

* Refactored Slack and Webhook integrations to pull configurations dynamically from a new Repository

* Added new TemplateProcessor and added/updated unit tests

* SlackService improvements, testing, integration configurations

* Refactor SlackService to use a dedicated model to parse responses

* Refactored SlackOAuthController to use SlackService as an injected dependency; added tests for SlackService

* Remove unnecessary methods from the IOrganizationIntegrationConfigurationRepository

* Moved Slack OAuth to take into account the Organization it's being stored for. Added methods to store the top level integration for Slack

* Organization integrations and configuration database schemas

* Format EF files

* Initial buildout of basic repositories

* [PM-17562] Add Dapper Repositories For Organization Integrations and Configurations

* Update Slack and Webhook handlers to use new Repositories

* Update SlackOAuth tests to new signatures

* Added EF Repositories

* Update handlers to use latest repositories

* [PM-17562] Add Dapper and EF Repositories For Ogranization Integrations and Configurations

* Updated with changes from PR comments

* Adjusted Handlers to new repository method names; updated tests to naming convention

* Adjust URL structure; add delete for Slack, add tests

* Added Webhook Integration Controller

* Add tests for WebhookIntegrationController

* Added Create/Delete for  OrganizationIntegrationConfigurations

* Prepend ConnectionTypes into IntegrationType so we don't run into issues later

* Added Update to OrganizationIntegrationConfigurtionController

* Moved Webhook-specific integration code to being a generic controller for everything but Slack

* Removed delete from SlackController - Deletes should happen through the normal Integration controller

* Fixed SlackController, reworked OIC Controller to use ids from URL and update the returned object

* Added parse/type checking for integration and integration configuration JSONs, Cleaned up GlobalSettings to remove old values

* Cleanup and fixes for Azure Service Bus support

* Clean up naming on TemplateProcessorTests

* Address SonarQube warnings/suggestions

* Expanded test coverage; Cleaned up tests

* Respond to PR Feedback

* Rename TemplateProcessor to IntegrationTemplateProcessor

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Brant DeBow
2025-04-23 10:44:43 -04:00
committed by GitHub
parent 722fae81b3
commit 90d831d9ef
35 changed files with 2880 additions and 57 deletions

View File

@ -2,6 +2,8 @@
public enum IntegrationType : int
{
Slack = 1,
Webhook = 2,
CloudBillingSync = 1,
Scim = 2,
Slack = 3,
Webhook = 4,
}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfiguration(string url);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfigurationDetils(string url);

View File

@ -0,0 +1,57 @@

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

@ -0,0 +1,11 @@
namespace Bit.Core.Services;
public interface ISlackService
{
Task<string> GetChannelIdAsync(string token, string channelName);
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
Task<string> GetDmChannelByEmailAsync(string token, string email);
string GetRedirectUrl(string redirectUrl);
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@ -0,0 +1,46 @@
using System.Text.Json;
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 class SlackEventHandler(
IOrganizationIntegrationConfigurationRepository configurationRepository,
ISlackService slackService)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
IntegrationType.Slack,
eventMessage.Type);
foreach (var configuration in configurations)
{
var config = configuration.MergedConfiguration.Deserialize<SlackIntegrationConfigurationDetails>();
if (config is null)
{
continue;
}
await slackService.SendSlackMessageByChannelIdAsync(
config.token,
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
config.channelId
);
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
}

View File

@ -0,0 +1,162 @@
using System.Net.Http.Headers;
using System.Net.Http.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";
public async Task<string> GetChannelIdAsync(string token, string channelName)
{
return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault();
}
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);
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 OpenDmChannel(token, userId);
}
public string GetRedirectUrl(string redirectUrl)
{
return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}";
}
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{
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
{
result = null;
}
if (result == 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 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;
await _httpClient.SendAsync(request);
}
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);
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
if (!result.Ok)
{
logger.LogError("Error retrieving Slack user ID: {Error}", result.Error);
return string.Empty;
}
return result.User.Id;
}
private async Task<string> OpenDmChannel(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);
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
if (!result.Ok)
{
logger.LogError("Error opening DM channel: {Error}", result.Error);
return string.Empty;
}
return result.Channel.Id;
}
}

View File

@ -1,30 +1,57 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
namespace Bit.Core.Services;
public class WebhookEventHandler(
IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings)
IOrganizationIntegrationConfigurationRepository configurationRepository)
: IEventMessageHandler
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl;
public const string HttpClientName = "WebhookEventHandlerHttpClient";
public async Task HandleEventAsync(EventMessage eventMessage)
{
var content = JsonContent.Create(eventMessage);
var response = await _httpClient.PostAsync(_webhookUrl, content);
response.EnsureSuccessStatusCode();
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
IntegrationType.Webhook,
eventMessage.Type);
foreach (var configuration in configurations)
{
var config = configuration.MergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
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();
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
var content = JsonContent.Create(eventMessages);
var response = await _httpClient.PostAsync(_webhookUrl, content);
response.EnsureSuccessStatusCode();
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
}

View File

@ -0,0 +1,36 @@
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 redirectUrl)
{
return string.Empty;
}
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
{
return Task.FromResult(0);
}
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
return Task.FromResult(string.Empty);
}
}

View File

@ -0,0 +1,23 @@
using System.Text.RegularExpressions;
namespace Bit.Core.AdminConsole.Utilities;
public static partial class IntegrationTemplateProcessor
{
[GeneratedRegex(@"#(\w+)#")]
private static partial Regex TokenRegex();
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 =>
{
var propertyName = match.Groups[1].Value;
var property = type.GetProperty(propertyName);
return property?.GetValue(values)?.ToString() ?? match.Value;
});
}
}

View File

@ -53,6 +53,7 @@ public class GlobalSettings : IGlobalSettings
public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings();
public virtual SqlSettings MySql { get; set; } = new SqlSettings();
public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" };
public virtual SlackSettings Slack { get; set; } = new SlackSettings();
public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings();
public virtual MailSettings Mail { get; set; } = new MailSettings();
public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings();
@ -271,10 +272,17 @@ public class GlobalSettings : IGlobalSettings
}
}
public class SlackSettings
{
public virtual string ApiBaseUrl { get; set; } = "https://slack.com/api";
public virtual string ClientId { get; set; }
public virtual string ClientSecret { get; set; }
public virtual string Scopes { get; set; }
}
public class EventLoggingSettings
{
public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
public virtual string WebhookUrl { get; set; }
public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings();
public class AzureServiceBusSettings
@ -283,6 +291,7 @@ public class GlobalSettings : IGlobalSettings
private string _topicName;
public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription";
public virtual string SlackSubscriptionName { get; set; } = "events-slack-subscription";
public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription";
public string ConnectionString
@ -307,6 +316,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue";
public virtual string WebhookQueueName { get; set; } = "events-webhook-queue";
public virtual string SlackQueueName { get; set; } = "events-slack-queue";
public string HostName
{