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:
parent
96b5ee80b5
commit
541218a37f
|
|
@ -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))
|
- **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
|
## 🔧 Improvements
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ The runtime can use in-memory stores by default or EF Core stores when persisten
|
||||||
|
|
||||||
## API Layer
|
## 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).
|
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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ Compare existing language modules:
|
||||||
6. Add endpoint tests or component coverage if behavior is important.
|
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.
|
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
|
## Add A Store Or Persistence Provider
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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
|
## Security Review Checklist
|
||||||
|
|
||||||
- Does the endpoint require authentication or a permission?
|
- Does the endpoint require authentication or a permission?
|
||||||
- Does mutable API behavior honor read-only mode?
|
- Does mutable API behavior honor read-only mode?
|
||||||
- Does the operation need tenant scoping?
|
- 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?
|
- Does persistence apply tenant ID filters and saving handlers?
|
||||||
- Are bootstrap credentials only for development or secret-managed environments?
|
- Are bootstrap credentials only for development or secret-managed environments?
|
||||||
- Does any diagnostic/logging feature expose sensitive data without redaction?
|
- Does any diagnostic/logging feature expose sensitive data without redaction?
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ The module extension is [UseWorkflowsApi](../../src/modules/Elsa.Workflows.Api/E
|
||||||
|
|
||||||
## Route Prefix
|
## 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
|
```csharp
|
||||||
var routePrefix = app.Services.GetRequiredService<IOptions<ApiEndpointOptions>>().Value.RoutePrefix;
|
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`.
|
With the default prefix, endpoint paths look like `/elsa/api/workflow-definitions`.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Elsa.Caching.Options;
|
using Elsa.Caching.Options;
|
||||||
using Elsa.Common.RecurringTasks;
|
using Elsa.Common.RecurringTasks;
|
||||||
using Elsa.Expressions.Helpers;
|
using Elsa.Expressions.Helpers;
|
||||||
using Elsa.Extensions;
|
using Elsa.Extensions;
|
||||||
using Elsa.Features.Services;
|
using Elsa.Features.Services;
|
||||||
|
using Elsa.Http.Options;
|
||||||
using Elsa.Identity.Multitenancy;
|
using Elsa.Identity.Multitenancy;
|
||||||
using Elsa.Persistence.EFCore.Extensions;
|
using Elsa.Persistence.EFCore.Extensions;
|
||||||
using Elsa.Persistence.EFCore.Modules.Management;
|
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.Options;
|
||||||
using Elsa.Workflows.Runtime.Tasks;
|
using Elsa.Workflows.Runtime.Tasks;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Options;
|
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 useStructuredLogs = false; // Enable to inspect backend logs from Elsa Studio.
|
||||||
const bool useMultitenancy = true;
|
const bool useMultitenancy = true;
|
||||||
const bool disableVariableWrappers = false;
|
const bool disableVariableWrappers = false;
|
||||||
|
const string elsaApiRateLimitingPolicy = "elsa-api";
|
||||||
|
const string httpWorkflowRateLimitingPolicy = "elsa-http-workflows";
|
||||||
|
|
||||||
ObjectConverter.StrictMode = true;
|
ObjectConverter.StrictMode = true;
|
||||||
|
|
||||||
|
|
@ -43,6 +48,9 @@ var services = builder.Services;
|
||||||
var configuration = builder.Configuration;
|
var configuration = builder.Configuration;
|
||||||
var identitySection = configuration.GetSection("Identity");
|
var identitySection = configuration.GetSection("Identity");
|
||||||
var identityTokenSection = identitySection.GetSection("Tokens");
|
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 configuredAllowLocalDistributedRuntimeLockProvider = configuration.GetValue<bool?>("DistributedRuntime:AllowLocalLockProviderInDistributedRuntime");
|
||||||
var allowLocalDistributedRuntimeLockProvider =
|
var allowLocalDistributedRuntimeLockProvider =
|
||||||
configuredAllowLocalDistributedRuntimeLockProvider ?? builder.Environment.IsDevelopment();
|
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<BookmarkQueuePurgeOptions>(options => options.Ttl = TimeSpan.FromSeconds(3600));
|
||||||
services.Configure<CachingOptions>(options => options.CacheDuration = TimeSpan.FromDays(1));
|
services.Configure<CachingOptions>(options => options.CacheDuration = TimeSpan.FromDays(1));
|
||||||
services.Configure<IncidentOptions>(options => options.DefaultIncidentStrategy = typeof(ContinueWithIncidentsStrategy));
|
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
|
services
|
||||||
.AddHealthChecks()
|
.AddHealthChecks()
|
||||||
.AddElsaReadinessChecks(includeDistributedLocks: true);
|
.AddElsaReadinessChecks(includeDistributedLocks: true);
|
||||||
|
|
@ -195,9 +239,24 @@ app.MapHealthChecks("/", new()
|
||||||
Predicate = _ => false
|
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.
|
// Routing used for SignalR.
|
||||||
app.UseRouting();
|
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.
|
// Security.
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
@ -206,14 +265,9 @@ app.UseAuthorization();
|
||||||
if (useMultitenancy)
|
if (useMultitenancy)
|
||||||
app.UseTenants();
|
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.
|
// Captures unhandled exceptions and returns a JSON response.
|
||||||
app.UseJsonSerializationErrorHandler();
|
app.UseJsonSerializationErrorHandler();
|
||||||
|
|
||||||
// Elsa HTTP Endpoint activities.
|
|
||||||
app.UseWorkflows();
|
app.UseWorkflows();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
`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)
|
## OpenTelemetry (MacOS)
|
||||||
|
|
||||||
COR_ENABLE_PROFILING=1
|
COR_ENABLE_PROFILING=1
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,15 @@
|
||||||
"text/html"
|
"text/html"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IngressRateLimiting": {
|
||||||
|
"Enabled": false,
|
||||||
|
"ApiPermitLimit": 120,
|
||||||
|
"ApiWindowSeconds": 60,
|
||||||
|
"ApiQueueLimit": 0,
|
||||||
|
"HttpWorkflowPermitLimit": 60,
|
||||||
|
"HttpWorkflowWindowSeconds": 60,
|
||||||
|
"HttpWorkflowQueueLimit": 0
|
||||||
|
},
|
||||||
"Identity": {
|
"Identity": {
|
||||||
"Tokens": {
|
"Tokens": {
|
||||||
"SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY",
|
"SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Elsa.Workflows;
|
using Elsa.Workflows;
|
||||||
|
|
@ -6,6 +7,7 @@ using FastEndpoints;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
|
@ -13,43 +15,144 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
namespace Elsa.Extensions;
|
namespace Elsa.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public static class WebApplicationExtensions
|
public static class WebApplicationExtensions
|
||||||
{
|
{
|
||||||
|
private static readonly RequestDelegate NotFoundRequestDelegate = context =>
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="app"></param>
|
/// <param name="app"></param>
|
||||||
/// <param name="routePrefix">The route prefix to apply to Elsa API endpoints.</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>
|
/// <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")
|
public static IApplicationBuilder UseWorkflowsApi(this IApplicationBuilder app, string routePrefix = "elsa/api")
|
||||||
{
|
{
|
||||||
return app.UseFastEndpoints(config =>
|
return app.UseFastEndpoints(config => ConfigureWorkflowsApi(config, 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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="routes">The <see cref="IEndpointRouteBuilder"/> to register the endpoints with.</param>
|
/// <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>
|
/// <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>
|
/// <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") =>
|
public static IEndpointRouteBuilder MapWorkflowsApi(this IEndpointRouteBuilder routes, string routePrefix = "elsa/api") =>
|
||||||
routes.MapFastEndpoints(config =>
|
routes.MapFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix));
|
||||||
{
|
|
||||||
config.Endpoints.RoutePrefix = routePrefix;
|
/// <summary>
|
||||||
config.Serializer.RequestDeserializer = DeserializeRequestAsync;
|
/// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix.
|
||||||
config.Serializer.ResponseSerializer = SerializeRequestAsync;
|
/// </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)
|
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);
|
: JsonSerializer.SerializeAsync(httpResponse.Body, dto, dto?.GetType() ?? typeof(object), serializerContext, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ namespace Elsa.FastEndpointConfigurators;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures FastEndpoints with Elsa-specific serialization options.
|
/// 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>
|
/// </summary>
|
||||||
[UsedImplicitly]
|
[UsedImplicitly]
|
||||||
public class ElsaFastEndpointsConfigurator : IFastEndpointsConfigurator
|
public class ElsaFastEndpointsConfigurator : IFastEndpointsConfigurator
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Elsa.Http.Middleware;
|
using Elsa.Http.Middleware;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
// ReSharper disable once CheckNamespace
|
// ReSharper disable once CheckNamespace
|
||||||
namespace Elsa.Extensions;
|
namespace Elsa.Extensions;
|
||||||
|
|
@ -13,4 +14,45 @@ public static class ApplicationBuilderExtensions
|
||||||
/// Installs the <see cref="HttpWorkflowsMiddleware"/> component.
|
/// Installs the <see cref="HttpWorkflowsMiddleware"/> component.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IApplicationBuilder UseWorkflows(this IApplicationBuilder app) => app.UseMiddleware<HttpWorkflowsMiddleware>();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,10 @@ public class HttpActivityOptions
|
||||||
/// and not waiting for the end of the HttpMiddleware.
|
/// and not waiting for the end of the HttpMiddleware.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool WriteHttpResponseSynchronously { get; set; } = false;
|
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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,10 @@ public class ApiEndpointOptions
|
||||||
/// The prefix used for API routes.
|
/// The prefix used for API routes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string RoutePrefix { get; set; } = "elsa/api";
|
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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue