1
0
mirror of https://github.com/bitwarden/server synced 2025-12-30 15:14:02 +00:00

[PM-17562] Add integration filter support (#5971)

* [PM-17562] Add integration filter support

* Repond to PR feedback; Remove Date-related filters

* Use tables to format the filter class descriptions

* [PM-17562] Add database support for integration filters (#5988)

* [PM-17562] Add database support for integration filters

* Respond to PR review - fix database scripts

* Further database updates; fix Filters to be last in views, stored procs, etc

* Fix for missing nulls in stored procedures in main migration script

* Reorder Filters to the bottom of OrganizationIntegrationConfiguration

* Separate out the creation of filters from the IntegrationFilterService to IntegrationFIlterFactory

* Move properties to static readonly field

* Fix unit tests failing from merge

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Brant DeBow
2025-06-26 16:03:05 -04:00
committed by GitHub
parent b951b38c37
commit 57cd628de8
33 changed files with 10677 additions and 31 deletions

View File

@@ -6,15 +6,18 @@ using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class EventIntegrationHandler<T>(
IntegrationType integrationType,
IEventIntegrationPublisher eventIntegrationPublisher,
IIntegrationFilterService integrationFilterService,
IOrganizationIntegrationConfigurationRepository configurationRepository,
IUserRepository userRepository,
IOrganizationRepository organizationRepository)
IOrganizationRepository organizationRepository,
ILogger<EventIntegrationHandler<T>> logger)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
@@ -31,25 +34,47 @@ public class EventIntegrationHandler<T>(
foreach (var configuration in configurations)
{
var template = configuration.Template ?? string.Empty;
var context = await BuildContextAsync(eventMessage, template);
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid();
var config = configuration.MergedConfiguration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}");
var message = new IntegrationMessage<T>
try
{
IntegrationType = integrationType,
MessageId = messageId.ToString(),
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,
DelayUntilDate = null
};
if (configuration.Filters is string filterJson)
{
// Evaluate filters - if false, then discard and do not process
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(filterJson)
?? throw new InvalidOperationException($"Failed to deserialize Filters to FilterGroup");
if (!integrationFilterService.EvaluateFilterGroup(filters, eventMessage))
{
continue;
}
}
await eventIntegrationPublisher.PublishAsync(message);
// Valid filter - assemble message and publish to Integration topic/exchange
var template = configuration.Template ?? string.Empty;
var context = await BuildContextAsync(eventMessage, template);
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid();
var config = configuration.MergedConfiguration.Deserialize<T>()
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name} - bad Configuration");
var message = new IntegrationMessage<T>
{
IntegrationType = integrationType,
MessageId = messageId.ToString(),
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,
DelayUntilDate = null
};
await eventIntegrationPublisher.PublishAsync(message);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to publish Integration Message for {Type}, check Id {RecordId} for error in Configuration or Filters",
typeof(T).Name,
configuration.Id);
}
}
}

View File

@@ -0,0 +1,51 @@
#nullable enable
using System.Linq.Expressions;
using Bit.Core.Models.Data;
namespace Bit.Core.Services;
public delegate bool IntegrationFilter(EventMessage message, object? value);
public static class IntegrationFilterFactory
{
public static IntegrationFilter BuildEqualityFilter<T>(string propertyName)
{
var param = Expression.Parameter(typeof(EventMessage), "m");
var valueParam = Expression.Parameter(typeof(object), "val");
var property = Expression.PropertyOrField(param, propertyName);
var typedVal = Expression.Convert(valueParam, typeof(T));
var body = Expression.Equal(property, typedVal);
var lambda = Expression.Lambda<Func<EventMessage, object?, bool>>(body, param, valueParam);
return new IntegrationFilter(lambda.Compile());
}
public static IntegrationFilter BuildInFilter<T>(string propertyName)
{
var param = Expression.Parameter(typeof(EventMessage), "m");
var valueParam = Expression.Parameter(typeof(object), "val");
var property = Expression.PropertyOrField(param, propertyName);
var method = typeof(Enumerable)
.GetMethods()
.FirstOrDefault(m =>
m.Name == "Contains"
&& m.GetParameters().Length == 2)
?.MakeGenericMethod(typeof(T));
if (method is null)
{
throw new InvalidOperationException("Could not find Contains method.");
}
var listType = typeof(IEnumerable<T>);
var castedList = Expression.Convert(valueParam, listType);
var containsCall = Expression.Call(method, castedList, property);
var lambda = Expression.Lambda<Func<EventMessage, object?, bool>>(containsCall, param, valueParam);
return new IntegrationFilter(lambda.Compile());
}
}

View File

@@ -0,0 +1,110 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Data;
namespace Bit.Core.Services;
public class IntegrationFilterService : IIntegrationFilterService
{
private readonly Dictionary<string, IntegrationFilter> _equalsFilters = new();
private readonly Dictionary<string, IntegrationFilter> _inFilters = new();
private static readonly string[] _filterableProperties = new[]
{
"UserId",
"InstallationId",
"ProviderId",
"CipherId",
"CollectionId",
"GroupId",
"PolicyId",
"OrganizationUserId",
"ProviderUserId",
"ProviderOrganizationId",
"ActingUserId",
"SecretId",
"ServiceAccountId"
};
public IntegrationFilterService()
{
BuildFilters();
}
public bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message)
{
var ruleResults = group.Rules?.Select(
rule => EvaluateRule(rule, message)
) ?? Enumerable.Empty<bool>();
var groupResults = group.Groups?.Select(
innerGroup => EvaluateFilterGroup(innerGroup, message)
) ?? Enumerable.Empty<bool>();
var results = ruleResults.Concat(groupResults);
return group.AndOperator ? results.All(r => r) : results.Any(r => r);
}
private bool EvaluateRule(IntegrationFilterRule rule, EventMessage message)
{
var key = rule.Property;
return rule.Operation switch
{
IntegrationFilterOperation.Equals => _equalsFilters.TryGetValue(key, out var equals) &&
equals(message, ToGuid(rule.Value)),
IntegrationFilterOperation.NotEquals => !(_equalsFilters.TryGetValue(key, out var equals) &&
equals(message, ToGuid(rule.Value))),
IntegrationFilterOperation.In => _inFilters.TryGetValue(key, out var inList) &&
inList(message, ToGuidList(rule.Value)),
IntegrationFilterOperation.NotIn => !(_inFilters.TryGetValue(key, out var inList) &&
inList(message, ToGuidList(rule.Value))),
_ => false
};
}
private void BuildFilters()
{
foreach (var property in _filterableProperties)
{
_equalsFilters[property] = IntegrationFilterFactory.BuildEqualityFilter<Guid?>(property);
_inFilters[property] = IntegrationFilterFactory.BuildInFilter<Guid?>(property);
}
}
private static Guid? ToGuid(object? value)
{
if (value is Guid guid)
{
return guid;
}
if (value is string stringValue)
{
return Guid.Parse(stringValue);
}
if (value is JsonElement jsonElement)
{
return jsonElement.GetGuid();
}
throw new InvalidCastException("Could not convert value to Guid");
}
private static IEnumerable<Guid?> ToGuidList(object? value)
{
if (value is IEnumerable<Guid?> guidList)
{
return guidList;
}
if (value is JsonElement { ValueKind: JsonValueKind.Array } jsonElement)
{
var list = new List<Guid?>();
foreach (var item in jsonElement.EnumerateArray())
{
list.Add(ToGuid(item));
}
return list;
}
throw new InvalidCastException("Could not convert value to Guid[]");
}
}

View File

@@ -228,6 +228,52 @@ Currently, there are integrations / handlers for Slack and webhooks (as mentione
- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
the database to determine what to publish at the integration level.
## Filtering
In addition to the ability to configure integrations mentioned above, organization admins can
also add `Filters` stored in the `OrganizationIntegrationConfiguration`. Filters are completely
optional and as simple or complex as organization admins want to make them. These are stored in
the database as JSON and serialized into an `IntegrationFilterGroup`. This is then passed to
the `IntegrationFilterService`, which evaluates it to a `bool`. If it's `true`, the integration
proceeds as above. If it's `false`, we ignore this event and do not route it to the integration
level.
### `IntegrationFilterGroup`
Logical AND / OR grouping of a number of rules and other subgroups.
| Property | Description |
|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `AndOperator` | Indicates whether **all** (`true`) or **any** (`false`) of the `Rules` and `Groups` must be true. This applies to _both_ the inner group and the list of rules; for instance, if this group contained Rule1 and Rule2 as well as Group1 and Group2:<br/><br/>`true`: `Rule1 && Rule2 && Group1 && Group2`<br>`false`: `Rule1 \|\| Rule2 \|\| Group1 \|\| Group2` |
| `Rules` | A list of `IntegrationFilterRule`. Can be null or empty, in which case it will return `true`. |
| `Groups` | A list of nested `IntegrationFilterGroup`. Can be null or empty, in which case it will return `true`. |
### `IntegrationFilterRule`
The core of the filtering framework to determine if the data in this specific EventMessage
matches the data for which the filter is searching.
| Property | Description |
|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Property` | The property on `EventMessage` to evaluate (e.g., `CollectionId`). |
| `Operation` | The comparison to perform between the property and `Value`. <br><br>**Supported operations:**<br>• `Equals`: `Guid` equals `Value`<br>• `NotEquals`: logical inverse of `Equals`<br>• `In`: `Guid` is in `Value` list<br>• `NotIn`: logical inverse of `In` |
| `Value` | The comparison value. Type depends on `Operation`: <br>• `Equals`, `NotEquals`: `Guid`<br>• `In`, `NotIn`: list of `Guid` |
```mermaid
graph TD
A[IntegrationFilterGroup]
A -->|Has 0..many| B1[IntegrationFilterRule]
A --> D1[And Operator]
A -->|Has 0..many| C1[Nested IntegrationFilterGroup]
B1 --> B2[Property: string]
B1 --> B3[Operation: Equals/In/DateBefore/DateAfter]
B1 --> B4[Value: object?]
C1 -->|Has many| B1_2[IntegrationFilterRule]
C1 -->|Can contain| C2[IntegrationFilterGroup...]
```
# Building a new integration
These are all the pieces required in the process of building out a new integration. For