Add ingress rate limiting hooks (#7512)

* Add ingress rate limiting hooks

* Fix ingress rate limiting middleware setup

* Harden rate limiter policy validation

* Preserve routed endpoints during rate limiting

* Address rate limiting review feedback

* Address rate limiting Copilot feedback

* Register rate limiter services for external policies

* Address rate limiting review comments

* Keep rate limiter service detection best effort

* Address rate limiting review comments

* Remove brittle rate limiter validation

* Address rate limiting review feedback

* Address rate limiting nullable review

* Address rate limiting review feedback

* Assign ingress rate limit policies when enabled

* Refine ingress rate limiting middleware cleanup

* Address rate limiting review feedback

* Align rate limiting review feedback

* Clarify rate limiting policy semantics

* Stabilize rate limiting exception tests

* Fix rate limiting endpoint matching default
This commit is contained in:
Sipke Schoorstra 2026-05-22 00:13:11 +02:00 committed by GitHub
parent 96b5ee80b5
commit 541218a37f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 768 additions and 32 deletions

View file

@ -65,6 +65,10 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3
- **Distributed locks for `Reload` / `Refresh`**: `IWorkflowDefinitionsReloader` and `IWorkflowDefinitionsRefresher` are now wrapped with distributed lock decorators to prevent multiple pods from concurrently executing reload or refresh operations. The refresher key incorporates definition IDs to allow concurrent refreshes of different definitions. ([689f19f2f2](https://github.com/elsa-workflows/elsa-core/commit/689f19f2f2)) ([#7311](https://github.com/elsa-workflows/elsa-core/pull/7311))
### Ingress rate limiting
- **Elsa API and HTTP workflow trigger rate limiting hooks**: Hosts can now apply named ASP.NET Core rate limiter policies to Elsa management API route prefixes and public HTTP workflow trigger base paths. The reference server includes disabled-by-default fixed-window policies under `IngressRateLimiting` with documented tuning and disable paths. ([#7488](https://github.com/elsa-workflows/elsa-core/issues/7488))
---
## 🔧 Improvements

View file

@ -102,7 +102,7 @@ The runtime can use in-memory stores by default or EF Core stores when persisten
## API Layer
[WorkflowsApiFeature](../../src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs) registers FastEndpoints from the module and depends on workflow management, workflow instances, workflow runtime, and SAS tokens. The default route prefix is `elsa/api`, defined in [ApiEndpointOptions](../../src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs) and applied by [UseWorkflowsApi](../../src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs).
[WorkflowsApiFeature](../../src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs) registers FastEndpoints from the module and depends on workflow management, workflow instances, workflow runtime, and SAS tokens. The default route prefix is `elsa/api`, defined in [ApiEndpointOptions](../../src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs) and applied by [MapWorkflowsApi](../../src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs) in endpoint-routed ASP.NET hosts.
Real-time workflow updates are in [RealTimeWorkflowUpdatesFeature](../../src/modules/Elsa.Workflows.Api/Features/RealTimeWorkflowUpdatesFeature.cs) and [WorkflowInstanceHub](../../src/modules/Elsa.Workflows.Api/RealTime/Hubs/WorkflowInstanceHub.cs).

View file

@ -72,7 +72,7 @@ Compare existing language modules:
6. Add endpoint tests or component coverage if behavior is important.
7. Update client models if the endpoint is part of the public client surface.
Use route prefixing from `UseWorkflowsApi`; endpoint routes should usually be written without `/elsa/api`.
Use route prefixing from `MapWorkflowsApi`; endpoint routes should usually be written without `/elsa/api`.
## Add A Store Or Persistence Provider

View file

@ -106,11 +106,21 @@ Structured logs define diagnostics permissions in [StructuredLogsPermissions](..
Identity endpoints and user-management endpoints are permission-based; see [ADR 0010](../adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md).
## Ingress Rate Limiting
Elsa exposes opt-in ASP.NET Core rate limiting hooks for two ingress surfaces:
- Elsa management API endpoints, through `ApiEndpointOptions.RateLimitingPolicyName` and `UseWorkflowsApiRateLimiting(...)`.
- Public HTTP workflow trigger routes, through `HttpActivityOptions.RateLimitingPolicyName` and `UseWorkflowsRateLimiting(...)`.
The reference server registers disabled-by-default fixed-window policies under `IngressRateLimiting`. Enable them by setting `IngressRateLimiting:Enabled` to `true`, then tune the API and HTTP workflow permit/window values for production traffic. Set `IngressRateLimiting:RegisterReferencePolicies` to `false` when policy names and policies are supplied externally. Custom hosts should register named policies with `services.AddRateLimiter(...)`, map Elsa API endpoints with `MapWorkflowsApi(...)`, apply the Elsa metadata hooks after endpoint routing has selected endpoints and before the rate limiter middleware, and call `app.UseRateLimiter()` once in the host pipeline. The Elsa hooks only attach endpoint metadata; ASP.NET Core validates configured policy names when the rate limiter middleware handles matching requests. Leave the option disabled, omit the policy names in custom hosts, or set the reference-server policy options to empty strings to run without Elsa-provided rate limiting. The reference server only assigns its default policy names when `Enabled` is `true`.
## Security Review Checklist
- Does the endpoint require authentication or a permission?
- Does mutable API behavior honor read-only mode?
- Does the operation need tenant scoping?
- Are exposed API or HTTP workflow trigger routes protected by appropriate ingress rate limiting?
- Does persistence apply tenant ID filters and saving handlers?
- Are bootstrap credentials only for development or secret-managed environments?
- Does any diagnostic/logging feature expose sensitive data without redaction?

View file

@ -20,11 +20,11 @@ The module extension is [UseWorkflowsApi](../../src/modules/Elsa.Workflows.Api/E
## Route Prefix
The default route prefix is `elsa/api`, defined in [ApiEndpointOptions](../../src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs). ASP.NET hosts apply it with [UseWorkflowsApi](../../src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs):
The default route prefix is `elsa/api`, defined in [ApiEndpointOptions](../../src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs). Endpoint-routed ASP.NET hosts apply it with [MapWorkflowsApi](../../src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs):
```csharp
var routePrefix = app.Services.GetRequiredService<IOptions<ApiEndpointOptions>>().Value.RoutePrefix;
app.UseWorkflowsApi(routePrefix);
app.MapWorkflowsApi(routePrefix);
```
With the default prefix, endpoint paths look like `/elsa/api/workflow-definitions`.

View file

@ -1,9 +1,11 @@
using System.Text.Encodings.Web;
using System.Threading.RateLimiting;
using Elsa.Caching.Options;
using Elsa.Common.RecurringTasks;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Features.Services;
using Elsa.Http.Options;
using Elsa.Identity.Multitenancy;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Persistence.EFCore.Modules.Management;
@ -26,6 +28,7 @@ using Elsa.Workflows.Runtime.Distributed.Extensions;
using Elsa.Workflows.Runtime.Options;
using Elsa.Workflows.Runtime.Tasks;
using JetBrains.Annotations;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
@ -35,6 +38,8 @@ const bool useSignalR = false; // Disabled until Elsa Studio sends authenticated
const bool useStructuredLogs = false; // Enable to inspect backend logs from Elsa Studio.
const bool useMultitenancy = true;
const bool disableVariableWrappers = false;
const string elsaApiRateLimitingPolicy = "elsa-api";
const string httpWorkflowRateLimitingPolicy = "elsa-http-workflows";
ObjectConverter.StrictMode = true;
@ -43,6 +48,9 @@ var services = builder.Services;
var configuration = builder.Configuration;
var identitySection = configuration.GetSection("Identity");
var identityTokenSection = identitySection.GetSection("Tokens");
var ingressRateLimitingSection = configuration.GetSection("IngressRateLimiting");
var useIngressRateLimiting = ingressRateLimitingSection.GetValue("Enabled", false);
var registerIngressRateLimitingPolicies = ingressRateLimitingSection.GetValue("RegisterReferencePolicies", useIngressRateLimiting);
var configuredAllowLocalDistributedRuntimeLockProvider = configuration.GetValue<bool?>("DistributedRuntime:AllowLocalLockProviderInDistributedRuntime");
var allowLocalDistributedRuntimeLockProvider =
configuredAllowLocalDistributedRuntimeLockProvider ?? builder.Environment.IsDevelopment();
@ -159,6 +167,42 @@ services.Configure<RuntimeOptions>(options => { options.InactivityThreshold = Ti
services.Configure<BookmarkQueuePurgeOptions>(options => options.Ttl = TimeSpan.FromSeconds(3600));
services.Configure<CachingOptions>(options => options.CacheDuration = TimeSpan.FromDays(1));
services.Configure<IncidentOptions>(options => options.DefaultIncidentStrategy = typeof(ContinueWithIncidentsStrategy));
if (useIngressRateLimiting)
{
services.PostConfigure<ApiEndpointOptions>(options =>
{
if (options.RateLimitingPolicyName == null)
options.RateLimitingPolicyName = elsaApiRateLimitingPolicy;
});
services.PostConfigure<HttpActivityOptions>(options =>
{
if (options.RateLimitingPolicyName == null)
options.RateLimitingPolicyName = httpWorkflowRateLimitingPolicy;
});
}
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
if (registerIngressRateLimitingPolicies)
{
options.AddFixedWindowLimiter(elsaApiRateLimitingPolicy, limiterOptions =>
{
limiterOptions.PermitLimit = ingressRateLimitingSection.GetValue("ApiPermitLimit", 120);
limiterOptions.Window = TimeSpan.FromSeconds(ingressRateLimitingSection.GetValue("ApiWindowSeconds", 60));
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = ingressRateLimitingSection.GetValue("ApiQueueLimit", 0);
});
options.AddFixedWindowLimiter(httpWorkflowRateLimitingPolicy, limiterOptions =>
{
limiterOptions.PermitLimit = ingressRateLimitingSection.GetValue("HttpWorkflowPermitLimit", 60);
limiterOptions.Window = TimeSpan.FromSeconds(ingressRateLimitingSection.GetValue("HttpWorkflowWindowSeconds", 60));
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = ingressRateLimitingSection.GetValue("HttpWorkflowQueueLimit", 0);
});
}
});
services
.AddHealthChecks()
.AddElsaReadinessChecks(includeDistributedLocks: true);
@ -195,9 +239,24 @@ app.MapHealthChecks("/", new()
Predicate = _ => false
});
// Elsa API endpoints for designer.
var apiEndpointOptions = app.Services.GetRequiredService<IOptions<ApiEndpointOptions>>().Value;
var routePrefix = apiEndpointOptions.RoutePrefix;
app.MapWorkflowsApi(routePrefix);
// Routing used for SignalR.
app.UseRouting();
app.UseWorkflowsApiRateLimiting(routePrefix, apiEndpointOptions.RateLimitingPolicyName);
// Elsa HTTP Endpoint activities.
var httpActivityOptions = app.Services.GetRequiredService<IOptions<HttpActivityOptions>>().Value;
app.UseWorkflowsRateLimiting(httpActivityOptions.BasePath, httpActivityOptions.RateLimitingPolicyName);
if (useIngressRateLimiting ||
!string.IsNullOrWhiteSpace(apiEndpointOptions.RateLimitingPolicyName) ||
!string.IsNullOrWhiteSpace(httpActivityOptions.RateLimitingPolicyName))
app.UseRateLimiter();
// Security.
app.UseAuthentication();
app.UseAuthorization();
@ -206,14 +265,9 @@ app.UseAuthorization();
if (useMultitenancy)
app.UseTenants();
// Elsa API endpoints for designer.
var routePrefix = app.Services.GetRequiredService<IOptions<ApiEndpointOptions>>().Value.RoutePrefix;
app.UseWorkflowsApi(routePrefix);
// Captures unhandled exceptions and returns a JSON response.
app.UseJsonSerializationErrorHandler();
// Elsa HTTP Endpoint activities.
app.UseWorkflows();
app.MapControllers();

View file

@ -6,6 +6,28 @@ This project represents an Elsa application that hosts workflows and exposes API
`appsettings.json` does not include production-usable default admin credentials or API keys. Configure initial users and applications through environment-specific configuration or a secret manager.
## Ingress Rate Limiting
The reference server includes opt-in ASP.NET Core rate limiting for Elsa management API requests and public HTTP workflow trigger routes. Enable it by setting `IngressRateLimiting:Enabled` to `true`.
Default policies are intentionally conservative and queue-free:
```json
"IngressRateLimiting": {
"Enabled": true,
"ApiPermitLimit": 120,
"ApiWindowSeconds": 60,
"ApiQueueLimit": 0,
"HttpWorkflowPermitLimit": 60,
"HttpWorkflowWindowSeconds": 60,
"HttpWorkflowQueueLimit": 0
}
```
Tune these values for production traffic and deployment topology. To disable the reference rate limiting behavior, leave `Enabled` as `false` and do not configure external policy names. Custom hosts can register their own named ASP.NET Core rate limiter policies with `services.AddRateLimiter(...)`, pass the policy names through `ApiEndpointOptions.RateLimitingPolicyName` and `HttpActivityOptions.RateLimitingPolicyName`, map Elsa API endpoints with `MapWorkflowsApi(...)`, call `UseWorkflowsApiRateLimiting(...)` and `UseWorkflowsRateLimiting(...)` after endpoint routing has selected endpoints, then call `app.UseRateLimiter()` once for the host pipeline. The Elsa hooks only attach endpoint metadata; ASP.NET Core validates configured policy names when the rate limiter middleware handles matching requests.
In the reference server, `IngressRateLimiting:RegisterReferencePolicies` controls whether the built-in fixed-window policies are registered; when it is unset, it defaults to the value of `Enabled`. `Enabled` controls default policy-name assignment and the reference-server middleware toggle. Externally configured policy names are preserved. Set a policy option to an empty string to explicitly disable rate limiting for that surface even when reference policies are registered.
## OpenTelemetry (MacOS)
COR_ENABLE_PROFILING=1

View file

@ -66,6 +66,15 @@
"text/html"
]
},
"IngressRateLimiting": {
"Enabled": false,
"ApiPermitLimit": 120,
"ApiWindowSeconds": 60,
"ApiQueueLimit": 0,
"HttpWorkflowPermitLimit": 60,
"HttpWorkflowWindowSeconds": 60,
"HttpWorkflowQueueLimit": 0
},
"Identity": {
"Tokens": {
"SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY",

View file

@ -1,4 +1,5 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Workflows;
@ -6,6 +7,7 @@ using FastEndpoints;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
@ -13,43 +15,144 @@ using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Extensions;
/// <summary>
/// Provides extension methods to add the FastEndpoints middleware configured for use with Elsa API endpoints.
/// Provides extension methods to add FastEndpoints configured for use with Elsa API endpoints.
/// </summary>
[PublicAPI]
public static class WebApplicationExtensions
{
private static readonly RequestDelegate NotFoundRequestDelegate = context =>
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
};
/// <summary>
/// Register the FastEndpoints middleware configured for use with with Elsa API endpoints.
/// Registers the FastEndpoints middleware configured for use with Elsa API endpoints.
/// </summary>
/// <param name="app"></param>
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</param>
/// <example>E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions"</example>
public static IApplicationBuilder UseWorkflowsApi(this IApplicationBuilder app, string routePrefix = "elsa/api")
{
return app.UseFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = routePrefix;
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
config.Serializer.ResponseSerializer = SerializeRequestAsync;
config.Binding.ValueParserFor<DateTimeOffset>(s =>
new(DateTimeOffset.TryParse(s.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result), result));
});
return app.UseFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix));
}
/// <summary>
/// Register the FastEndpoints middleware configured for use with with Elsa API endpoints.
/// Applies an ASP.NET Core rate limiting policy to requests targeting the Elsa API route prefix.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="routePrefix">The route prefix used by Elsa API endpoints.</param>
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting.</param>
public static IApplicationBuilder UseWorkflowsApiRateLimiting(this IApplicationBuilder app, string routePrefix = "elsa/api", string? policyName = null)
{
if (string.IsNullOrWhiteSpace(policyName))
return app;
var pathPrefix = NormalizeRoutePrefixPath(routePrefix);
return app.UseRateLimitingPolicyForPath(pathPrefix, policyName, "Elsa API rate limiting endpoint", requireMatchedEndpoint: true);
}
/// <summary>
/// Maps FastEndpoints endpoint routes configured for use with Elsa API endpoints.
/// </summary>
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to register the endpoints with.</param>
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</param>
/// <example>E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions"</example>
public static IEndpointRouteBuilder MapWorkflowsApi(this IEndpointRouteBuilder routes, string routePrefix = "elsa/api") =>
routes.MapFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = routePrefix;
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
config.Serializer.ResponseSerializer = SerializeRequestAsync;
});
routes.MapFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix));
/// <summary>
/// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="pathPrefix">The path prefix to protect.</param>
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name.</param>
/// <param name="displayName">The endpoint display name used for rate limiting metadata.</param>
/// <remarks>
/// This method only attaches rate limiting metadata. In endpoint-routed pipelines, call this after routing has selected an endpoint
/// and before the host's single <c>app.UseRateLimiter()</c> middleware. ASP.NET Core validates the configured policy when the
/// rate limiter middleware handles matching requests.
/// </remarks>
public static IApplicationBuilder UseRateLimitingPolicyForPath(this IApplicationBuilder app, PathString pathPrefix, string policyName, string displayName) =>
app.UseRateLimitingPolicyForPath(pathPrefix, policyName, displayName, true);
/// <summary>
/// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="pathPrefix">The path prefix to protect.</param>
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name.</param>
/// <param name="displayName">The endpoint display name used for rate limiting metadata.</param>
/// <param name="requireMatchedEndpoint">Whether to skip rate limiting when endpoint routing selected no endpoint.</param>
public static IApplicationBuilder UseRateLimitingPolicyForPath(this IApplicationBuilder app, PathString pathPrefix, string policyName, string displayName, bool requireMatchedEndpoint)
{
if (!pathPrefix.HasValue || string.IsNullOrWhiteSpace(policyName))
return app;
var rateLimitingMetadata = new EnableRateLimitingAttribute(policyName);
var fallbackEndpoint = CreateRateLimitingEndpoint(null, rateLimitingMetadata, displayName);
var endpointCache = new ConditionalWeakTable<Endpoint, Endpoint>();
return app.UseWhen(
context => context.Request.Path.StartsWithSegments(pathPrefix, StringComparison.OrdinalIgnoreCase),
branch =>
{
branch.Use(async (context, next) =>
{
var originalEndpoint = context.GetEndpoint();
if (requireMatchedEndpoint && originalEndpoint == null)
{
await next(context);
return;
}
var rateLimitingEndpoint = originalEndpoint == null
? fallbackEndpoint
: endpointCache.GetValue(originalEndpoint, endpoint => CreateRateLimitingEndpoint(endpoint, rateLimitingMetadata, displayName));
context.SetEndpoint(rateLimitingEndpoint);
try
{
await next(context);
}
finally
{
if (ReferenceEquals(context.GetEndpoint(), rateLimitingEndpoint))
context.SetEndpoint(originalEndpoint);
}
});
});
}
private static PathString NormalizeRoutePrefixPath(string routePrefix)
{
var value = routePrefix.Trim().Trim('/');
return string.IsNullOrEmpty(value) ? PathString.Empty : new PathString("/" + value);
}
private static void ConfigureWorkflowsApi(Config config, string routePrefix)
{
config.Endpoints.RoutePrefix = routePrefix;
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
config.Serializer.ResponseSerializer = SerializeRequestAsync;
config.Binding.ValueParserFor<DateTimeOffset>(s =>
new(DateTimeOffset.TryParse(s.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result), result));
}
private static Endpoint CreateRateLimitingEndpoint(Endpoint? originalEndpoint, EnableRateLimitingAttribute rateLimitingMetadata, string displayName)
{
var metadata = originalEndpoint == null
? new EndpointMetadataCollection(rateLimitingMetadata)
: new EndpointMetadataCollection(originalEndpoint.Metadata.Where(x => x is not EnableRateLimitingAttribute and not DisableRateLimitingAttribute).Concat([rateLimitingMetadata]));
if (originalEndpoint is RouteEndpoint routeEndpoint)
return new RouteEndpoint(routeEndpoint.RequestDelegate ?? NotFoundRequestDelegate, routeEndpoint.RoutePattern, routeEndpoint.Order, metadata, routeEndpoint.DisplayName ?? displayName);
return new Endpoint(originalEndpoint?.RequestDelegate ?? NotFoundRequestDelegate, metadata, originalEndpoint?.DisplayName ?? displayName);
}
private static ValueTask<object?> DeserializeRequestAsync(HttpRequest httpRequest, Type modelType, JsonSerializerContext? serializerContext, CancellationToken cancellationToken)
{
@ -72,4 +175,4 @@ public static class WebApplicationExtensions
: JsonSerializer.SerializeAsync(httpResponse.Body, dto, dto?.GetType() ?? typeof(object), serializerContext, cancellationToken);
}
}
}

View file

@ -12,7 +12,8 @@ namespace Elsa.FastEndpointConfigurators;
/// <summary>
/// Configures FastEndpoints with Elsa-specific serialization options.
/// Uses the same serialization settings as <see cref="Elsa.Extensions.WebApplicationExtensions.UseWorkflowsApi"/>.
/// Uses the same serialization settings as <see cref="Elsa.Extensions.WebApplicationExtensions.UseWorkflowsApi"/>
/// and <see cref="Elsa.Extensions.WebApplicationExtensions.MapWorkflowsApi"/>.
/// </summary>
[UsedImplicitly]
public class ElsaFastEndpointsConfigurator : IFastEndpointsConfigurator

View file

@ -1,5 +1,6 @@
using Elsa.Http.Middleware;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
@ -13,4 +14,45 @@ public static class ApplicationBuilderExtensions
/// Installs the <see cref="HttpWorkflowsMiddleware"/> component.
/// </summary>
public static IApplicationBuilder UseWorkflows(this IApplicationBuilder app) => app.UseMiddleware<HttpWorkflowsMiddleware>();
}
/// <summary>
/// Applies an ASP.NET Core rate limiting policy to inbound HTTP workflow trigger routes.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="basePath">The HTTP workflow trigger base path to protect.</param>
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting.</param>
public static IApplicationBuilder UseWorkflowsRateLimiting(this IApplicationBuilder app, PathString? basePath, string? policyName)
{
var normalizedBasePath = basePath?.ToString();
return app.UseWorkflowsRateLimiting(normalizedBasePath, policyName);
}
/// <summary>
/// Applies an ASP.NET Core rate limiting policy to inbound HTTP workflow trigger routes.
/// </summary>
/// <param name="app">The application builder.</param>
/// <param name="basePath">The HTTP workflow trigger base path to protect.</param>
/// <param name="policyName">The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting.</param>
public static IApplicationBuilder UseWorkflowsRateLimiting(this IApplicationBuilder app, string? basePath, string? policyName)
{
if (string.IsNullOrWhiteSpace(policyName) || string.IsNullOrWhiteSpace(basePath))
return app;
var pathPrefix = NormalizeBasePath(basePath);
return pathPrefix.HasValue
? app.UseRateLimitingPolicyForPath(pathPrefix, policyName, "Elsa HTTP workflow trigger rate limiting endpoint", requireMatchedEndpoint: false)
: app;
}
private static PathString NormalizeBasePath(string basePath)
{
var value = basePath.Trim();
if (string.IsNullOrEmpty(value))
return PathString.Empty;
value = value.Trim('/');
return string.IsNullOrEmpty(value) ? PathString.Empty : new PathString("/" + value);
}
}

View file

@ -43,4 +43,10 @@ public class HttpActivityOptions
/// and not waiting for the end of the HttpMiddleware.
/// </summary>
public bool WriteHttpResponseSynchronously { get; set; } = false;
}
/// <summary>
/// The ASP.NET Core rate limiting policy to apply to inbound HTTP workflow trigger routes.
/// <c>null</c> means unspecified and allows a host to assign a default policy; an empty string disables HTTP workflow trigger rate limiting.
/// </summary>
public string? RateLimitingPolicyName { get; set; }
}

View file

@ -6,4 +6,10 @@ public class ApiEndpointOptions
/// The prefix used for API routes.
/// </summary>
public string RoutePrefix { get; set; } = "elsa/api";
}
/// <summary>
/// The ASP.NET Core rate limiting policy to apply to Elsa API endpoints.
/// <c>null</c> means unspecified and allows a host to assign a default policy; an empty string disables Elsa API rate limiting.
/// </summary>
public string? RateLimitingPolicyName { get; set; }
}

View file

@ -0,0 +1,479 @@
using System.Net;
using System.Threading.RateLimiting;
using Elsa.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Http.UnitTests.RateLimiting;
public class IngressRateLimitingTests
{
private const string PolicyName = "test";
[Fact]
public async Task UseWorkflowsApiRateLimiting_AppliesPolicyToApiPrefix()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_AppliesPolicyToHttpWorkflowBasePath()
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/workflows/hello-world");
var secondResponse = await client.GetAsync("/workflows/hello-world");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Theory]
[InlineData("/workflows/")]
[InlineData("workflows")]
public async Task UseWorkflowsRateLimiting_NormalizesHttpWorkflowBasePath(string basePath)
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting(basePath, PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/workflows/hello-world");
var secondResponse = await client.GetAsync("/workflows/hello-world");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_NormalizesRoutePrefixWhitespace()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting(" elsa/api ", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_DoesNotApplyWhitespaceOnlyRoutePrefixToAllPaths()
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsApiRateLimiting(" ", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/other/path");
var secondResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_DoesNotApplyPolicyToOtherPaths()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
await client.GetAsync("/elsa/api/ping");
await client.GetAsync("/elsa/api/ping");
var otherResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.NotFound, otherResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_DoesNotApplyPolicyToOtherPaths()
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
await client.GetAsync("/workflows/hello-world");
await client.GetAsync("/workflows/hello-world");
var otherResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.OK, otherResponse.StatusCode);
}
[Theory]
[InlineData("/")]
[InlineData(" / ")]
public async Task UseWorkflowsRateLimiting_DoesNotApplyRootBasePathToAllPaths(string basePath)
{
await using var app = await CreateAppAsync(app => app.UseWorkflowsRateLimiting(basePath, PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/other/path");
var secondResponse = await client.GetAsync("/other/path");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_AppliesPolicyToMiddlewarePathWhenEndpointRoutesExist()
{
await using var app = await CreateAppWithEndpointRouteAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/workflows/hello-world");
var secondResponse = await client.GetAsync("/workflows/hello-world");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseRateLimitingPolicyForPath_DefaultOverloadRequiresMatchedEndpoint()
{
await using var app = await CreateAppWithEndpointRouteAsync(app => app.UseRateLimitingPolicyForPath("/proxy", PolicyName, "Proxy rate limiting endpoint"));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/proxy/downstream");
var secondResponse = await client.GetAsync("/proxy/downstream");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_UsesExistingGlobalRateLimiterMiddleware()
{
var policy = new CountingRateLimiterPolicy();
await using var app = await CreateRoutedAppAsync(
app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName),
options => options.AddPolicy(PolicyName, policy));
var client = app.GetTestClient();
var partitionRequestCount = policy.PartitionRequestCount;
var firstResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.True(policy.PartitionRequestCount > partitionRequestCount);
partitionRequestCount = policy.PartitionRequestCount;
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
Assert.True(policy.PartitionRequestCount > partitionRequestCount);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_PreservesRoutedEndpointExecution()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var content = await firstResponse.Content.ReadAsStringAsync();
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal("pong", content);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_PreservesUnmatchedApiPrefixRouting()
{
await using var app = await CreateRoutedAppAsync(app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName));
var client = app.GetTestClient();
var unmatchedResponse = await client.GetAsync("/elsa/api/not-found");
var routedResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.NotFound, unmatchedResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, routedResponse.StatusCode);
}
[Fact]
public async Task UseWorkflowsRateLimiting_PreservesEndpointRoutingNotFoundForUnmatchedPath()
{
await using var app = await CreateEndpointRoutedAppAsync(app => app.UseWorkflowsRateLimiting("/workflows", PolicyName));
var client = app.GetTestClient();
var response = await client.GetAsync("/workflows/not-found");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_CachesAugmentedRouteEndpointAndPreservesRouteDetails()
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
RouteEndpoint? originalEndpoint = null;
RouteEndpoint? firstAugmentedEndpoint = null;
RouteEndpoint? secondAugmentedEndpoint = null;
var requestCount = 0;
var routeMetadata = new TestRouteMetadata("ping");
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong")
.WithDisplayName("Elsa API Ping")
.WithMetadata(routeMetadata);
app.UseRouting();
app.Use(async (context, next) =>
{
originalEndpoint ??= Assert.IsType<RouteEndpoint>(context.GetEndpoint());
await next(context);
});
app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName);
app.Use(async (context, next) =>
{
var augmentedEndpoint = Assert.IsType<RouteEndpoint>(context.GetEndpoint());
requestCount++;
if (requestCount == 1)
firstAugmentedEndpoint = augmentedEndpoint;
else
secondAugmentedEndpoint = augmentedEndpoint;
await next(context);
});
app.UseRateLimiter();
});
await using (app)
{
app.Configure();
await app.StartAsync();
var client = app.GetTestClient();
var firstResponse = await client.GetAsync("/elsa/api/ping");
var secondResponse = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode);
}
Assert.NotNull(originalEndpoint);
Assert.NotNull(firstAugmentedEndpoint);
Assert.NotNull(secondAugmentedEndpoint);
Assert.NotSame(originalEndpoint, firstAugmentedEndpoint);
Assert.Same(firstAugmentedEndpoint, secondAugmentedEndpoint);
Assert.Equal(originalEndpoint.RoutePattern.RawText, firstAugmentedEndpoint.RoutePattern.RawText);
Assert.Equal(originalEndpoint.Order, firstAugmentedEndpoint.Order);
Assert.Equal(originalEndpoint.DisplayName, firstAugmentedEndpoint.DisplayName);
Assert.Same(routeMetadata, firstAugmentedEndpoint.Metadata.GetMetadata<TestRouteMetadata>());
Assert.Equal(PolicyName, firstAugmentedEndpoint.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_ReplacesExistingRateLimitingMetadata()
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
RouteEndpoint? augmentedEndpoint = null;
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong")
.RequireRateLimiting("other")
.DisableRateLimiting();
app.UseRouting();
app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName);
app.Use(async (context, next) =>
{
augmentedEndpoint ??= Assert.IsType<RouteEndpoint>(context.GetEndpoint());
await next(context);
});
app.UseRateLimiter();
});
await using (app)
{
app.Configure();
await app.StartAsync();
var client = app.GetTestClient();
var response = await client.GetAsync("/elsa/api/ping");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
Assert.NotNull(augmentedEndpoint);
var enableRateLimitingMetadata = augmentedEndpoint.Metadata.OfType<EnableRateLimitingAttribute>().ToList();
Assert.Single(enableRateLimitingMetadata);
Assert.Equal(PolicyName, enableRateLimitingMetadata.Single().PolicyName);
Assert.DoesNotContain(augmentedEndpoint.Metadata, x => x is DisableRateLimitingAttribute);
}
[Fact]
public async Task UseWorkflowsApiRateLimiting_FailsWhenPolicyIsNotRegistered()
{
await using var app = CreateRoutedApp(
app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName),
options => AddFixedWindowLimiter(options, "other"));
app.Configure();
await app.StartAsync();
var client = app.GetTestClient();
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetAsync("/elsa/api/ping"));
}
[Fact]
public void UseWorkflowsApiRateLimiting_UsesFrameworkServiceValidation()
{
using var app = CreateApp(
app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName),
registerRateLimiter: false);
Assert.Throws<InvalidOperationException>(() => app.Configure());
}
private static async Task<TestApplication> CreateAppAsync(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null)
{
var app = CreateApp(configure, configureRateLimiter);
app.Configure();
await app.StartAsync();
return app;
}
private static async Task<TestApplication> CreateRoutedAppAsync(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null)
{
var app = CreateRoutedApp(configure, configureRateLimiter);
app.Configure();
await app.StartAsync();
return app;
}
private static TestApplication CreateRoutedApp(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null)
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services, configureRateLimiter);
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong");
app.UseRouting();
configure(app);
app.UseRateLimiter();
});
return app;
}
private static async Task<TestApplication> CreateAppWithEndpointRouteAsync(Action<WebApplication> configure)
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong");
app.UseRouting();
configure(app);
app.UseRateLimiter();
app.Run(context => context.Response.WriteAsync("ok"));
});
app.Configure();
await app.StartAsync();
return app;
}
private static async Task<TestApplication> CreateEndpointRoutedAppAsync(Action<WebApplication> configure)
{
var builder = CreateBuilder();
AddRateLimiterServices(builder.Services);
var app = new TestApplication(builder.Build(), app =>
{
app.MapGet("/elsa/api/ping", () => "pong");
app.UseRouting();
configure(app);
app.UseRateLimiter();
app.UseEndpoints(_ => { });
});
app.Configure();
await app.StartAsync();
return app;
}
private static TestApplication CreateApp(Action<WebApplication> configure, Action<RateLimiterOptions>? configureRateLimiter = null, bool registerRateLimiter = true)
{
var builder = CreateBuilder();
if (registerRateLimiter)
AddRateLimiterServices(builder.Services, configureRateLimiter);
return new TestApplication(builder.Build(), app =>
{
configure(app);
app.UseRateLimiter();
app.Run(context => context.Response.WriteAsync("ok"));
});
}
private static WebApplicationBuilder CreateBuilder()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseTestServer();
return builder;
}
private static void AddRateLimiterServices(IServiceCollection services, Action<RateLimiterOptions>? configureRateLimiter = null)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
if (configureRateLimiter == null)
AddFixedWindowLimiter(options, PolicyName);
else
configureRateLimiter(options);
});
}
private static void AddFixedWindowLimiter(RateLimiterOptions options, string policyName)
{
options.AddFixedWindowLimiter(policyName, limiterOptions =>
{
limiterOptions.PermitLimit = 1;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0;
});
}
private sealed class TestApplication(WebApplication app, Action<WebApplication> configure) : IAsyncDisposable, IDisposable
{
public void Configure() => configure(app);
public HttpClient GetTestClient() => app.GetTestClient();
public Task StartAsync() => app.StartAsync();
public void Dispose() => app.DisposeAsync().AsTask().GetAwaiter().GetResult();
public ValueTask DisposeAsync() => app.DisposeAsync();
}
private sealed class CountingRateLimiterPolicy : IRateLimiterPolicy<string>
{
public int PartitionRequestCount { get; private set; }
public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected => null;
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
PartitionRequestCount++;
return RateLimitPartition.GetFixedWindowLimiter(PolicyName, _ => new()
{
PermitLimit = 1,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
}
}
private sealed record TestRouteMetadata(string Value);
}