Skip HTTP workflow route matching outside base path (#7757)
* Skip HTTP workflow route matching outside base path * Handle tenant-prefixed HTTP workflow base paths Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Narrow tenant-prefixed HTTP base-path matching Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Fix sibling-prefix HTTP base-path matching * Limit HTTP base-path precheck depth * Use resolved tenant paths for HTTP routing --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
This commit is contained in:
parent
9cce8d91af
commit
7f1f1e50e9
|
|
@ -40,22 +40,17 @@ public class HttpWorkflowsMiddleware(RequestDelegate next)
|
|||
IHttpWorkflowLookupService httpWorkflowLookupService)
|
||||
{
|
||||
var path = httpContext.Request.Path.Value!.NormalizeRoute();
|
||||
var matchingPath = GetMatchingRoute(serviceProvider, path).Route;
|
||||
var basePath = options.Value.BasePath?.ToString().NormalizeRoute();
|
||||
|
||||
// If the request path does not match the configured base path to handle workflows, then skip.
|
||||
if (!string.IsNullOrWhiteSpace(basePath))
|
||||
if (!string.IsNullOrWhiteSpace(basePath) && !IsBasePathMatch(path, basePath))
|
||||
{
|
||||
if (!path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip the base path.
|
||||
matchingPath = matchingPath[basePath.Length..];
|
||||
await next(httpContext);
|
||||
return;
|
||||
}
|
||||
|
||||
var matchingPath = GetMatchingRoute(serviceProvider, path).Route;
|
||||
matchingPath = TryStripBasePath(matchingPath, basePath) ?? matchingPath;
|
||||
|
||||
// Graceful-shutdown gate: when the runtime is paused or draining, we don't accept new HTTP-triggered work.
|
||||
// The ingress source registry visibility is provided by HttpTriggerIngressSource — this is the actual mechanism.
|
||||
var quiescenceSignal = serviceProvider.GetService<IQuiescenceSignal>();
|
||||
|
|
@ -277,11 +272,58 @@ public class HttpWorkflowsMiddleware(RequestDelegate next)
|
|||
};
|
||||
|
||||
var matchingRoute = matchingRouteQuery.FirstOrDefault();
|
||||
var routeTemplate = matchingRoute?.route ?? new HttpRouteData(path);
|
||||
|
||||
return routeTemplate;
|
||||
return matchingRoute?.route ?? new HttpRouteData(path);
|
||||
}
|
||||
|
||||
private static string? TryStripBasePath(string route, string? basePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basePath) || basePath == "/")
|
||||
return route;
|
||||
|
||||
var routeSegments = GetRouteSegments(route);
|
||||
var basePathSegments = GetRouteSegments(basePath);
|
||||
var basePathIndex = FindSegmentSequence(routeSegments, basePathSegments);
|
||||
|
||||
if (basePathIndex != 0)
|
||||
return null;
|
||||
|
||||
var remainingSegments = routeSegments.Skip(basePathIndex + basePathSegments.Length);
|
||||
return remainingSegments.Any() ? $"/{string.Join('/', remainingSegments)}" : "/";
|
||||
}
|
||||
|
||||
private static bool IsBasePathMatch(string route, string basePath) =>
|
||||
basePath == "/" ||
|
||||
string.Equals(route, basePath, StringComparison.OrdinalIgnoreCase) ||
|
||||
route.StartsWith($"{basePath}/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static int FindSegmentSequence(string[] routeSegments, string[] candidateSegments)
|
||||
{
|
||||
if (candidateSegments.Length == 0)
|
||||
return 0;
|
||||
|
||||
for (var startIndex = 0; startIndex <= routeSegments.Length - candidateSegments.Length; startIndex++)
|
||||
{
|
||||
var isMatch = true;
|
||||
|
||||
for (var candidateIndex = 0; candidateIndex < candidateSegments.Length; candidateIndex++)
|
||||
{
|
||||
if (!string.Equals(routeSegments[startIndex + candidateIndex], candidateSegments[candidateIndex], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMatch)
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string[] GetRouteSegments(string route) => route.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
private async Task<string?> GetCorrelationIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, CancellationToken cancellationToken)
|
||||
{
|
||||
var correlationIdSelectors = serviceProvider.GetServices<IHttpCorrelationIdSelector>();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using Elsa.Workflows.Runtime.Filters;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Elsa.Http.UnitTests.Middleware;
|
||||
|
||||
|
|
@ -55,6 +56,184 @@ public class HttpWorkflowsMiddlewareTests
|
|||
Assert.False(filter.TenantAgnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithConfiguredBasePathAndNonMatchingPath_SkipsRouteMatchingAndCallsNext()
|
||||
{
|
||||
var nextCalled = false;
|
||||
var middleware = new HttpWorkflowsMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var serviceProvider = new ServiceCollection().BuildServiceProvider();
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = serviceProvider
|
||||
};
|
||||
httpContext.Request.Path = "/health";
|
||||
|
||||
await middleware.InvokeAsync(
|
||||
httpContext,
|
||||
serviceProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }),
|
||||
new EmptyHttpWorkflowLookupService());
|
||||
|
||||
Assert.True(nextCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithSiblingPrefixPath_SkipsRouteMatchingAndCallsNext()
|
||||
{
|
||||
var nextCalled = false;
|
||||
var middleware = new HttpWorkflowsMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var serviceProvider = new ServiceCollection().BuildServiceProvider();
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = serviceProvider
|
||||
};
|
||||
httpContext.Request.Path = "/workflows-v2/status";
|
||||
|
||||
await middleware.InvokeAsync(
|
||||
httpContext,
|
||||
serviceProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }),
|
||||
new EmptyHttpWorkflowLookupService());
|
||||
|
||||
Assert.True(nextCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithMultiplePrefixSegmentsBeforeBasePath_SkipsRouteMatchingAndCallsNext()
|
||||
{
|
||||
var nextCalled = false;
|
||||
var routeMatcher = Substitute.For<IRouteMatcher>();
|
||||
var middleware = new HttpWorkflowsMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton(routeMatcher)
|
||||
.AddSingleton<IRouteTable>(new ListRouteTable([new("/api/v1/workflows/status")]))
|
||||
.BuildServiceProvider();
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = serviceProvider
|
||||
};
|
||||
httpContext.Request.Path = "/api/v1/workflows/status";
|
||||
|
||||
await middleware.InvokeAsync(
|
||||
httpContext,
|
||||
serviceProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }),
|
||||
new EmptyHttpWorkflowLookupService());
|
||||
|
||||
Assert.True(nextCalled);
|
||||
routeMatcher.DidNotReceive().Match(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithNonTenantPrefixedBasePathSegment_CallsNext()
|
||||
{
|
||||
var nextCalled = false;
|
||||
var routeMatcher = Substitute.For<IRouteMatcher>();
|
||||
var middleware = new HttpWorkflowsMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton(routeMatcher)
|
||||
.AddSingleton<IRouteTable>(new ListRouteTable([new("/{tenantPrefix}/workflows/colliding")]))
|
||||
.BuildServiceProvider();
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = serviceProvider
|
||||
};
|
||||
httpContext.Request.Path = "/api/workflows/colliding";
|
||||
|
||||
await middleware.InvokeAsync(
|
||||
httpContext,
|
||||
serviceProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }),
|
||||
new EmptyHttpWorkflowLookupService());
|
||||
|
||||
Assert.True(nextCalled);
|
||||
routeMatcher.DidNotReceive().Match(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithConfiguredBasePathAndMatchingPath_StillResolvesRoute()
|
||||
{
|
||||
var routeMatcher = Substitute.For<IRouteMatcher>();
|
||||
routeMatcher.Match("/workflows/colliding", "/workflows/colliding").Returns(new RouteValueDictionary());
|
||||
var bookmarkStore = new CapturingBookmarkStore(CurrentTenantId, CreateCollidingHttpEndpointBookmarks());
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IBookmarkStore>(bookmarkStore)
|
||||
.AddSingleton(routeMatcher)
|
||||
.AddSingleton<IRouteTable>(new ListRouteTable([new("/workflows/colliding")]))
|
||||
.AddSingleton<IStimulusHasher, FixedStimulusHasher>()
|
||||
.BuildServiceProvider();
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = serviceProvider
|
||||
};
|
||||
httpContext.Request.Path = "/workflows/colliding";
|
||||
httpContext.Request.Method = HttpMethod.Get.Method;
|
||||
|
||||
await _middleware.InvokeAsync(
|
||||
httpContext,
|
||||
serviceProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }),
|
||||
new EmptyHttpWorkflowLookupService());
|
||||
|
||||
routeMatcher.Received(1).Match("/workflows/colliding", "/workflows/colliding");
|
||||
Assert.NotNull(bookmarkStore.LastFilter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WithResolvedTenantPath_StillResolvesRoute()
|
||||
{
|
||||
var nextCalled = false;
|
||||
var routeMatcher = Substitute.For<IRouteMatcher>();
|
||||
var stimulusHasher = new CapturingStimulusHasher();
|
||||
var middleware = new HttpWorkflowsMiddleware(_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
routeMatcher.Match("/workflows/colliding", "/workflows/colliding").Returns(new RouteValueDictionary());
|
||||
var bookmarkStore = new CapturingBookmarkStore(CurrentTenantId, CreateCollidingHttpEndpointBookmarks());
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<IBookmarkStore>(bookmarkStore)
|
||||
.AddSingleton(routeMatcher)
|
||||
.AddSingleton<IRouteTable>(new ListRouteTable([new("/workflows/colliding")]))
|
||||
.AddSingleton<IStimulusHasher>(stimulusHasher)
|
||||
.BuildServiceProvider();
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = serviceProvider
|
||||
};
|
||||
httpContext.Request.PathBase = "/acme";
|
||||
httpContext.Request.Path = "/workflows/colliding";
|
||||
httpContext.Request.Method = HttpMethod.Get.Method;
|
||||
|
||||
await middleware.InvokeAsync(
|
||||
httpContext,
|
||||
serviceProvider,
|
||||
Microsoft.Extensions.Options.Options.Create(new HttpActivityOptions { BasePath = "/workflows" }),
|
||||
new EmptyHttpWorkflowLookupService());
|
||||
|
||||
Assert.False(nextCalled);
|
||||
routeMatcher.Received(1).Match("/workflows/colliding", "/workflows/colliding");
|
||||
Assert.Equal("/colliding", stimulusHasher.LastPayload?.Path);
|
||||
Assert.NotNull(bookmarkStore.LastFilter);
|
||||
}
|
||||
|
||||
private static IEnumerable<StoredBookmark> CreateCollidingHttpEndpointBookmarks()
|
||||
{
|
||||
yield return CreateBookmark("current-tenant-bookmark", CurrentTenantId);
|
||||
|
|
@ -143,6 +322,17 @@ public class HttpWorkflowsMiddlewareTests
|
|||
public string Hash(string stimulusName, object? payload = null, string? activityInstanceId = null) => BookmarkHash;
|
||||
}
|
||||
|
||||
private class CapturingStimulusHasher : IStimulusHasher
|
||||
{
|
||||
public HttpEndpointBookmarkPayload? LastPayload { get; private set; }
|
||||
|
||||
public string Hash(string stimulusName, object? payload = null, string? activityInstanceId = null)
|
||||
{
|
||||
LastPayload = payload as HttpEndpointBookmarkPayload;
|
||||
return BookmarkHash;
|
||||
}
|
||||
}
|
||||
|
||||
private class ExactRouteMatcher : IRouteMatcher
|
||||
{
|
||||
public RouteValueDictionary? Match(string routeTemplate, string route) => routeTemplate == route ? new() : null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue