From 541218a37f0067894d5a32c12e78a8c438fdc42c Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 22 May 2026 00:13:11 +0200 Subject: [PATCH] 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 --- doc/changelogs/3.6.0.md | 4 + doc/wiki/architecture.md | 2 +- doc/wiki/extension-guide.md | 2 +- doc/wiki/identity-tenancy-security.md | 10 + doc/wiki/workflow-api.md | 4 +- src/apps/Elsa.Server.Web/Program.cs | 64 ++- src/apps/Elsa.Server.Web/README.md | 22 + src/apps/Elsa.Server.Web/appsettings.json | 9 + .../Extensions/WebApplicationExtensions.cs | 141 +++++- .../ElsaFastEndpointsConfigurator.cs | 3 +- .../ApplicationBuilderExtensions.cs | 44 +- .../Elsa.Http/Options/HttpActivityOptions.cs | 8 +- .../Options/ApiEndpointOptions.cs | 8 +- .../RateLimiting/IngressRateLimitingTests.cs | 479 ++++++++++++++++++ 14 files changed, 768 insertions(+), 32 deletions(-) create mode 100644 test/unit/Elsa.Http.UnitTests/RateLimiting/IngressRateLimitingTests.cs diff --git a/doc/changelogs/3.6.0.md b/doc/changelogs/3.6.0.md index 8963f905f..434719dba 100644 --- a/doc/changelogs/3.6.0.md +++ b/doc/changelogs/3.6.0.md @@ -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 diff --git a/doc/wiki/architecture.md b/doc/wiki/architecture.md index 50f5751ad..f458d11e5 100644 --- a/doc/wiki/architecture.md +++ b/doc/wiki/architecture.md @@ -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). diff --git a/doc/wiki/extension-guide.md b/doc/wiki/extension-guide.md index f269db320..11dc97adb 100644 --- a/doc/wiki/extension-guide.md +++ b/doc/wiki/extension-guide.md @@ -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 diff --git a/doc/wiki/identity-tenancy-security.md b/doc/wiki/identity-tenancy-security.md index 026c749a5..1844a5c61 100644 --- a/doc/wiki/identity-tenancy-security.md +++ b/doc/wiki/identity-tenancy-security.md @@ -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? diff --git a/doc/wiki/workflow-api.md b/doc/wiki/workflow-api.md index 25381c8a9..7476e9dc6 100644 --- a/doc/wiki/workflow-api.md +++ b/doc/wiki/workflow-api.md @@ -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>().Value.RoutePrefix; -app.UseWorkflowsApi(routePrefix); +app.MapWorkflowsApi(routePrefix); ``` With the default prefix, endpoint paths look like `/elsa/api/workflow-definitions`. diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index 8e61fadbd..6f3c615d5 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -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("DistributedRuntime:AllowLocalLockProviderInDistributedRuntime"); var allowLocalDistributedRuntimeLockProvider = configuredAllowLocalDistributedRuntimeLockProvider ?? builder.Environment.IsDevelopment(); @@ -159,6 +167,42 @@ services.Configure(options => { options.InactivityThreshold = Ti services.Configure(options => options.Ttl = TimeSpan.FromSeconds(3600)); services.Configure(options => options.CacheDuration = TimeSpan.FromDays(1)); services.Configure(options => options.DefaultIncidentStrategy = typeof(ContinueWithIncidentsStrategy)); +if (useIngressRateLimiting) +{ + services.PostConfigure(options => + { + if (options.RateLimitingPolicyName == null) + options.RateLimitingPolicyName = elsaApiRateLimitingPolicy; + }); + services.PostConfigure(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>().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>().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>().Value.RoutePrefix; -app.UseWorkflowsApi(routePrefix); - // Captures unhandled exceptions and returns a JSON response. app.UseJsonSerializationErrorHandler(); -// Elsa HTTP Endpoint activities. app.UseWorkflows(); app.MapControllers(); diff --git a/src/apps/Elsa.Server.Web/README.md b/src/apps/Elsa.Server.Web/README.md index bdbd855f0..893dbfccc 100644 --- a/src/apps/Elsa.Server.Web/README.md +++ b/src/apps/Elsa.Server.Web/README.md @@ -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 diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index a1b740393..266f45451 100644 --- a/src/apps/Elsa.Server.Web/appsettings.json +++ b/src/apps/Elsa.Server.Web/appsettings.json @@ -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", diff --git a/src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs b/src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs index 42a836fa2..8add55875 100644 --- a/src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs +++ b/src/common/Elsa.Api.Common/Extensions/WebApplicationExtensions.cs @@ -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; /// -/// 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. /// [PublicAPI] public static class WebApplicationExtensions { + private static readonly RequestDelegate NotFoundRequestDelegate = context => + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }; + /// - /// Register the FastEndpoints middleware configured for use with with Elsa API endpoints. + /// Registers the FastEndpoints middleware configured for use with Elsa API endpoints. /// /// /// The route prefix to apply to Elsa API endpoints. /// E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions" 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(s => - new(DateTimeOffset.TryParse(s.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result), result)); - }); + return app.UseFastEndpoints(config => ConfigureWorkflowsApi(config, routePrefix)); } /// - /// 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. + /// + /// The application builder. + /// The route prefix used by Elsa API endpoints. + /// The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting. + 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); + } + + /// + /// Maps FastEndpoints endpoint routes configured for use with Elsa API endpoints. /// /// The to register the endpoints with. /// The route prefix to apply to Elsa API endpoints. /// E.g. "elsa/api" will expose endpoints like this: "/elsa/api/workflow-definitions" 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)); + + /// + /// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix. + /// + /// The application builder. + /// The path prefix to protect. + /// The registered ASP.NET Core rate limiting policy name. + /// The endpoint display name used for rate limiting metadata. + /// + /// 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 app.UseRateLimiter() middleware. ASP.NET Core validates the configured policy when the + /// rate limiter middleware handles matching requests. + /// + public static IApplicationBuilder UseRateLimitingPolicyForPath(this IApplicationBuilder app, PathString pathPrefix, string policyName, string displayName) => + app.UseRateLimitingPolicyForPath(pathPrefix, policyName, displayName, true); + + /// + /// Applies an ASP.NET Core rate limiting policy to requests targeting the specified path prefix. + /// + /// The application builder. + /// The path prefix to protect. + /// The registered ASP.NET Core rate limiting policy name. + /// The endpoint display name used for rate limiting metadata. + /// Whether to skip rate limiting when endpoint routing selected no endpoint. + 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(); + + 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(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 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); } -} \ No newline at end of file +} diff --git a/src/common/Elsa.Api.Common/FastEndpointConfigurators/ElsaFastEndpointsConfigurator.cs b/src/common/Elsa.Api.Common/FastEndpointConfigurators/ElsaFastEndpointsConfigurator.cs index 947399a20..9cf5507e8 100644 --- a/src/common/Elsa.Api.Common/FastEndpointConfigurators/ElsaFastEndpointsConfigurator.cs +++ b/src/common/Elsa.Api.Common/FastEndpointConfigurators/ElsaFastEndpointsConfigurator.cs @@ -12,7 +12,8 @@ namespace Elsa.FastEndpointConfigurators; /// /// Configures FastEndpoints with Elsa-specific serialization options. -/// Uses the same serialization settings as . +/// Uses the same serialization settings as +/// and . /// [UsedImplicitly] public class ElsaFastEndpointsConfigurator : IFastEndpointsConfigurator diff --git a/src/modules/Elsa.Http/Extensions/ApplicationBuilderExtensions.cs b/src/modules/Elsa.Http/Extensions/ApplicationBuilderExtensions.cs index 1d74eea74..07aca40f2 100644 --- a/src/modules/Elsa.Http/Extensions/ApplicationBuilderExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/ApplicationBuilderExtensions.cs @@ -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 component. /// public static IApplicationBuilder UseWorkflows(this IApplicationBuilder app) => app.UseMiddleware(); -} \ No newline at end of file + + /// + /// Applies an ASP.NET Core rate limiting policy to inbound HTTP workflow trigger routes. + /// + /// The application builder. + /// The HTTP workflow trigger base path to protect. + /// The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting. + public static IApplicationBuilder UseWorkflowsRateLimiting(this IApplicationBuilder app, PathString? basePath, string? policyName) + { + var normalizedBasePath = basePath?.ToString(); + return app.UseWorkflowsRateLimiting(normalizedBasePath, policyName); + } + + /// + /// Applies an ASP.NET Core rate limiting policy to inbound HTTP workflow trigger routes. + /// + /// The application builder. + /// The HTTP workflow trigger base path to protect. + /// The registered ASP.NET Core rate limiting policy name. Leave empty to skip rate limiting. + 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); + } +} diff --git a/src/modules/Elsa.Http/Options/HttpActivityOptions.cs b/src/modules/Elsa.Http/Options/HttpActivityOptions.cs index e86a259bd..3e73eed76 100644 --- a/src/modules/Elsa.Http/Options/HttpActivityOptions.cs +++ b/src/modules/Elsa.Http/Options/HttpActivityOptions.cs @@ -43,4 +43,10 @@ public class HttpActivityOptions /// and not waiting for the end of the HttpMiddleware. /// public bool WriteHttpResponseSynchronously { get; set; } = false; -} \ No newline at end of file + + /// + /// The ASP.NET Core rate limiting policy to apply to inbound HTTP workflow trigger routes. + /// null means unspecified and allows a host to assign a default policy; an empty string disables HTTP workflow trigger rate limiting. + /// + public string? RateLimitingPolicyName { get; set; } +} diff --git a/src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs b/src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs index 2e7505928..062fbf01d 100644 --- a/src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs +++ b/src/modules/Elsa.Workflows.Api/Options/ApiEndpointOptions.cs @@ -6,4 +6,10 @@ public class ApiEndpointOptions /// The prefix used for API routes. /// public string RoutePrefix { get; set; } = "elsa/api"; -} \ No newline at end of file + + /// + /// The ASP.NET Core rate limiting policy to apply to Elsa API endpoints. + /// null means unspecified and allows a host to assign a default policy; an empty string disables Elsa API rate limiting. + /// + public string? RateLimitingPolicyName { get; set; } +} diff --git a/test/unit/Elsa.Http.UnitTests/RateLimiting/IngressRateLimitingTests.cs b/test/unit/Elsa.Http.UnitTests/RateLimiting/IngressRateLimitingTests.cs new file mode 100644 index 000000000..4bdffea11 --- /dev/null +++ b/test/unit/Elsa.Http.UnitTests/RateLimiting/IngressRateLimitingTests.cs @@ -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(context.GetEndpoint()); + await next(context); + }); + app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName); + app.Use(async (context, next) => + { + var augmentedEndpoint = Assert.IsType(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()); + Assert.Equal(PolicyName, firstAugmentedEndpoint.Metadata.GetMetadata()?.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(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().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(() => client.GetAsync("/elsa/api/ping")); + } + + [Fact] + public void UseWorkflowsApiRateLimiting_UsesFrameworkServiceValidation() + { + using var app = CreateApp( + app => app.UseWorkflowsApiRateLimiting("elsa/api", PolicyName), + registerRateLimiter: false); + + Assert.Throws(() => app.Configure()); + } + + private static async Task CreateAppAsync(Action configure, Action? configureRateLimiter = null) + { + var app = CreateApp(configure, configureRateLimiter); + app.Configure(); + await app.StartAsync(); + return app; + } + + private static async Task CreateRoutedAppAsync(Action configure, Action? configureRateLimiter = null) + { + var app = CreateRoutedApp(configure, configureRateLimiter); + app.Configure(); + await app.StartAsync(); + return app; + } + + private static TestApplication CreateRoutedApp(Action configure, Action? 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 CreateAppWithEndpointRouteAsync(Action 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 CreateEndpointRoutedAppAsync(Action 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 configure, Action? 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? 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 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 + { + public int PartitionRequestCount { get; private set; } + + public Func? OnRejected => null; + + public RateLimitPartition 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); +}