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:
@ -2,6 +2,8 @@
|
||||
|
||||
public enum IntegrationType : int
|
||||
{
|
||||
Slack = 1,
|
||||
Webhook = 2,
|
||||
CloudBillingSync = 1,
|
||||
Scim = 2,
|
||||
Slack = 3,
|
||||
Webhook = 4,
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegration(string token);
|
@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string channelId);
|
@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegrationConfigurationDetails(string channelId, string token);
|
@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.Models.Data.Integrations;
|
||||
|
||||
public record WebhookIntegrationConfiguration(string url);
|
@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.Models.Data.Integrations;
|
||||
|
||||
public record WebhookIntegrationConfigurationDetils(string url);
|
57
src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs
Normal file
57
src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs
Normal 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();
|
||||
}
|
11
src/Core/AdminConsole/Services/ISlackService.cs
Normal file
11
src/Core/AdminConsole/Services/ISlackService.cs
Normal 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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
162
src/Core/AdminConsole/Services/Implementations/SlackService.cs
Normal file
162
src/Core/AdminConsole/Services/Implementations/SlackService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
||||
|
Reference in New Issue
Block a user