elsa-core/src/modules/Elsa.Http/Middleware/HttpWorkflowsMiddleware.cs
Sipke Schoorstra 939fb95a97
Add multitenancy support for background tasks (#6059)
* Remove initial migrations

Deleted obsolete initial migration files from multiple databases: MySQL, SQL Server, SQLite, and PostgreSQL. This cleanup helps maintain a streamlined and updated migration history.

* Add Document base class and create tenant-specific indices

Introduced a new abstract `Document` base class to unify common properties. Implemented tenant-specific unique indices across multiple collections by including `TenantId` alongside `Id` to ensure uniqueness within tenant scopes.

* Remove outdated migration files

Deleted various migration files under MySql, PostgreSql, Sqlite, and SqlServer directories. This cleanup removes unnecessary schema definitions and helps to streamline the codebase.

* Refactor workflow identity assignment logic

Streamline workflow identity handling to ensure consistent assignment of Id, DefinitionId, and TenantId values. This change integrates tenant prefix and version suffix cleanly, enhancing clarity and maintainability.

* Enable multitenancy support

Added configuration for a new tenant (tenant-1) in appsettings.json and enabled multitenancy feature in Program.cs. This change allows the application to support multiple tenants, with specific configurations for each.

* Refactor route table update to run as startup task

Replaced `UpdateRouteTableHostedService` with `UpdateRouteTableStartupTask` to ensure route table updates are executed during application startup instead of as a hosted service. Updated configuration in `HttpFeature` and adjusted trigger validation logic in `ValidateWorkflowRequestHandler`.

* Add recurring task scheduling and single-node task support.

Introduce `IntervalExpressionType`, recurring task scheduling classes, and `SingleNodeTaskAttribute`. Update `RecurringTasksRunner` to handle schedules and add single-node task logic to `StartupTasksRunner`. Ensure proper namespace changes and configure sample recurring tasks.

* Refactor recurring tasks scheduling system

Replaced existing scheduling classes with a more modular and granular approach. Introduced new classes and interfaces like `ISchedule`, `CronSchedule`, `IntervalSchedule`, and `RecurringTaskScheduleManager`. Updated related methods and code to comply with the new design.

* Refactor background task management

Removed `ExpiredSecretsHostedService` and refactored it into a recurring task. Introduced `TaskExecutor` for shared task execution logic. Updated and renamed feature classes to better represent their purpose, improving task scheduling and execution management.

* Add BackgroundTask abstract class to Elsa.Common module

This new abstract class implements the IBackgroundTask interface with default methods for executing, starting, and stopping tasks asynchronously. It provides a basic framework for background task management in the Elsa.Common module.

* Switch to CreateAsyncScope in DefaultTenantScopeFactory

Updated the CreateScope method to use CreateAsyncScope instead of CreateScope. This change improves asynchronous handling of service scopes within the DefaultTenantScopeFactory class.

* Add tenant handling and move StartWorkers background task

Introduce ITenantAccessor in Worker class for multitenancy support. Rename and relocate StartWorkers service to BackgroundTask, ensuring smoother workflow initialization. Also, update the configuration to support Azure Service Bus connection string.

* Add tenant support and refactor ProtoActor client

Integrated ITenantAccessor in ProtoActorWorkflowClient class to handle multi-tenancy. Refactored methods in the client to support custom headers and added async disposable pattern in various services for proper resource management. Additionally, enabled Azure Service Bus and updated related documentation.

* Add support for custom headers in ProtoActor grain methods

Introduced a T4 template to generate grain methods with custom headers, enabling the use of tenant ID in requests. Updated `ProtoActorWorkflowClient` to employ these methods, removing redundant code and directly utilizing the client for various workflow operations.

* Add tenant middleware to MassTransit configurations

Introduced multitenancy middleware for MassTransit message handling. Added new message type `OrderReceived` and updated RabbitMQ setup in Elsa Server. Applied middleware to configure tenant data on send, publish, and consume operations.

* Add new product workflow and streamline ID handling

Introduced a new `RequestResponseWorkflow` for handling product requests. Simplified ID handling in `WorkflowBuilder` and `ClrWorkflowsProvider` by defaulting to empty strings and adding a version prefix. Enhanced `HttpWorkflowsMiddleware` to correctly parse full request paths.

* Remove redundant files and update configuration

Deleted unused files `Product.cs` and `RequestResponseWorkflow.cs` to clean up the codebase. Updated `Program.cs` configuration: switched MassTransitBroker to Memory and disabled multitenancy.

* Remove MultitenantRecurringTaskService and update AzureServiceBus

Removed `MultitenantRecurringTaskService` and adjusted related code for Azure Service Bus to work without it. This includes removal of tenant accessor dependency from `Worker` and cleanup of service configuration flags in `Program.cs`.

* Increase signal wait timeout to 10000 milliseconds.

Extended the default timeout for signal awaiting methods from 8000 to 10000 milliseconds. This change ensures more flexible and resilient waiting periods, reducing timeout occurrences in scenarios with longer processing times.

* Refactor scheduling service to be a background task

Renamed `CreateSchedulesHostedService` to `CreateSchedulesBackgroundTask` and refactored it to inherit from `BackgroundTask` instead of `BackgroundService`. Simplified the constructor by injecting the required dependencies directly, eliminating the need for a scoped factory.

* Refactor workflow version suffix formatting

Changed the version suffix format from `:v{version}` to `v{version}` and adjusted the ID concatenation accordingly. This improves consistency and readability of workflow IDs.

* Enable multitenancy support in Quartz scheduler

Added `TenantJobListener` to inject tenant context into jobs. Modified `QuartzWorkflowScheduler` to incorporate tenant IDs into job data maps and adjusted the configuration to acknowledge multitenancy settings.

* Remove ConfigureSchedulerHostedService and TenantJobListener

Consolidated tenant resolution logic into JobExecutionExtensions class. Updated ResumeWorkflowJob and RunWorkflowJob to use the new extension method for tenant retrieval. This simplifies the QuartzSchedulerFeature setup by removing the hosted service configuration.

* Refactor HTTP feature and update route table task

Move 'UpdateRouteTableStartupTask' from 'HostedServices' to 'Tasks' and update dependency injection configurations accordingly. Simplify 'DefaultRouteTableUpdater' by removing unnecessary options and tenant-agnostic settings from filters.

* Disable multitenancy in Program.cs

The useMultitenancy flag has been changed from true to false. This update affects the Elsa.Server.Web application configuration.

* Simplify variable usage in HttpWorkflowsMiddleware

Replaced 'fullPath' variable with 'path' to streamline code. This change enhances readability by reducing redundancy and ensures consistency in variable naming throughout the method.

* Enable multitenancy and refactor tenant handling logic

Enable multitenancy in the application and refactor tenant handling logic to use ITenantFinder and ITenantContextInitializer interfaces. Added header constants, updated middleware to use these interfaces, and moved extension methods to the appropriate namespace.

* Add input validation to user registration form

Implemented checks to ensure all required fields are filled and that input data adheres to format requirements. This change reduces errors and enhances form reliability.

* Remove unused import from TenantPrefixHttpEndpointRoutesProvider

This change cleans up the code by removing an unnecessary import statement. It improves code readability and reduces clutter, making future maintenance easier. The functionality remains unchanged.

* Rename filter scope to "tenantPublish" in Probe method

Updated the Probe method in TenantPublishMiddleware.cs to use "tenantPublish" instead of "tenantSend" for better clarity. Ensures consistency with the method's context and aligns with naming conventions.

* Refactor: Remove extraneous whitespace

Eliminate unnecessary whitespace in ProtoActorWorkflowClient.cs for cleaner code. This change helps maintain consistent formatting and improves readability.

* Refactor DefaultRegistriesPopulator for cleaner initialization

Converted constructor to use read-only fields directly, removing unnecessary instance variables. This change simplifies the code by reducing redundancy and making the constructor cleaner.
2024-10-28 19:38:24 +01:00

381 lines
16 KiB
C#

using Elsa.Extensions;
using Elsa.Http.Bookmarks;
using Elsa.Http.Options;
using Elsa.Workflows.Runtime.Filters;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Mime;
using System.Text.Json;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Helpers;
using Elsa.Workflows.Runtime.Entities;
using FastEndpoints;
using System.Diagnostics.CodeAnalysis;
using Elsa.Common.Multitenancy;
using Elsa.Workflows;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Models;
using Elsa.Workflows.Options;
using Elsa.Workflows.Runtime;
using Open.Linq.AsyncExtensions;
namespace Elsa.Http.Middleware;
/// <summary>
/// An ASP.NET middleware component that tries to match the inbound request path to an associated workflow and then run that workflow.
/// </summary>
[PublicAPI]
public class HttpWorkflowsMiddleware(RequestDelegate next, ITenantAccessor tenantAccessor, IOptions<HttpActivityOptions> options)
{
private readonly string _activityTypeName = ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>();
/// <summary>
/// Attempts to match the inbound request path to an associated workflow and then run that workflow.
/// </summary>
[RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
public async Task InvokeAsync(HttpContext httpContext, IServiceProvider serviceProvider)
{
var path = GetPath(httpContext);
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 (!path.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
{
await next(httpContext);
return;
}
// Strip the base path.
path = path[basePath.Length..];
matchingPath = matchingPath[basePath.Length..];
}
matchingPath = matchingPath.NormalizeRoute();
var input = new Dictionary<string, object>
{
[HttpEndpoint.HttpContextInputKey] = true,
[HttpEndpoint.RequestPathInputKey] = path
};
var cancellationToken = httpContext.RequestAborted;
var request = httpContext.Request;
var method = request.Method.ToLowerInvariant();
var httpWorkflowLookupService = serviceProvider.GetRequiredService<IHttpWorkflowLookupService>();
var workflowInstanceId = await GetWorkflowInstanceIdAsync(serviceProvider, httpContext, cancellationToken);
var correlationId = await GetCorrelationIdAsync(serviceProvider, httpContext, cancellationToken);
var bookmarkHash = ComputeBookmarkHash(serviceProvider, matchingPath, method);
var lookupResult = await httpWorkflowLookupService.FindWorkflowAsync(bookmarkHash, cancellationToken);
if (lookupResult != null)
{
var triggers = lookupResult.Triggers;
if (triggers.Count > 1)
{
await HandleMultipleWorkflowsFoundAsync(httpContext, () => triggers.Select(x => new
{
x.WorkflowDefinitionId
}), cancellationToken);
return;
}
var trigger = triggers.FirstOrDefault();
if (trigger != null)
{
var workflowGraph = lookupResult.WorkflowGraph!;
await StartWorkflowAsync(httpContext, trigger, workflowGraph, input, workflowInstanceId, correlationId);
return;
}
}
var bookmarks = await FindBookmarksAsync(serviceProvider, bookmarkHash, workflowInstanceId, correlationId, cancellationToken).ToList();
if (bookmarks.Count > 1)
{
await HandleMultipleWorkflowsFoundAsync(httpContext, () => bookmarks.Select(x => new
{
x.WorkflowInstanceId
}), cancellationToken);
return;
}
var bookmark = bookmarks.SingleOrDefault();
if (bookmark != null)
{
await ResumeWorkflowAsync(httpContext, bookmark, input, correlationId);
return;
}
// If a base path was configured, the requester tried to execute a workflow that doesn't exist.
if (basePath != null)
{
await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
return;
}
// If no base path was configured, the request should be handled by subsequent middlewares.
await next(httpContext);
}
private async Task<WorkflowGraph?> FindWorkflowGraphAsync(IServiceProvider serviceProvider, StoredTrigger trigger, CancellationToken cancellationToken)
{
var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
var workflowDefinitionId = trigger.WorkflowDefinitionVersionId;
return await workflowDefinitionService.FindWorkflowGraphAsync(workflowDefinitionId, cancellationToken);
}
private async Task<IEnumerable<StoredTrigger>> FindTriggersAsync(IServiceProvider serviceProvider, string bookmarkHash, CancellationToken cancellationToken)
{
var triggerStore = serviceProvider.GetRequiredService<ITriggerStore>();
var triggerFilter = new TriggerFilter
{
Hash = bookmarkHash
};
return await triggerStore.FindManyAsync(triggerFilter, cancellationToken);
}
private async Task<IEnumerable<StoredBookmark>> FindBookmarksAsync(IServiceProvider serviceProvider, string bookmarkHash, string? workflowInstanceId, string? correlationId, CancellationToken cancellationToken)
{
var bookmarkStore = serviceProvider.GetRequiredService<IBookmarkStore>();
var bookmarkFilter = new BookmarkFilter
{
Hash = bookmarkHash,
WorkflowInstanceId = workflowInstanceId,
CorrelationId = correlationId,
TenantAgnostic = true
};
return await bookmarkStore.FindManyAsync(bookmarkFilter, cancellationToken);
}
private async Task StartWorkflowAsync(HttpContext httpContext, StoredTrigger trigger, WorkflowGraph workflowGraph, IDictionary<string, object> input, string? workflowInstanceId, string? correlationId)
{
var bookmarkPayload = trigger.GetPayload<HttpEndpointBookmarkPayload>();
var workflowOptions = new RunWorkflowOptions
{
Input = input,
CorrelationId = correlationId,
TriggerActivityId = trigger.ActivityId,
WorkflowInstanceId = workflowInstanceId
};
await ExecuteWorkflowAsync(httpContext, workflowGraph, workflowOptions, bookmarkPayload, null, input);
}
private async Task ResumeWorkflowAsync(HttpContext httpContext, StoredBookmark bookmark, IDictionary<string, object> input, string? correlationId)
{
var serviceProvider = httpContext.RequestServices;
var cancellationToken = httpContext.RequestAborted;
var bookmarkPayload = bookmark.GetPayload<HttpEndpointBookmarkPayload>();
var workflowInstanceStore = serviceProvider.GetRequiredService<IWorkflowInstanceStore>();
var workflowInstance = await workflowInstanceStore.FindAsync(bookmark.WorkflowInstanceId, cancellationToken);
if (workflowInstance == null)
{
await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
return;
}
var workflowDefinitionService = serviceProvider.GetRequiredService<IWorkflowDefinitionService>();
var workflowGraph = await workflowDefinitionService.FindWorkflowGraphAsync(workflowInstance.DefinitionVersionId, cancellationToken);
if (workflowGraph == null)
{
await httpContext.Response.SendNotFoundAsync(cancellation: cancellationToken);
return;
}
var runWorkflowParams = new RunWorkflowOptions
{
WorkflowInstanceId = workflowInstance.Id,
Input = input,
CorrelationId = correlationId,
ActivityHandle = bookmark.ActivityInstanceId != null ? ActivityHandle.FromActivityInstanceId(bookmark.ActivityInstanceId) : null,
BookmarkId = bookmark.Id
};
await ExecuteWorkflowAsync(httpContext, workflowGraph, runWorkflowParams, bookmarkPayload, workflowInstance, input);
}
private async Task ExecuteWorkflowAsync(HttpContext httpContext, WorkflowGraph workflowGraph, RunWorkflowOptions workflowOptions, HttpEndpointBookmarkPayload bookmarkPayload, WorkflowInstance? workflowInstance, IDictionary<string, object> input)
{
var serviceProvider = httpContext.RequestServices;
var cancellationToken = httpContext.RequestAborted;
var workflow = workflowGraph.Workflow;
if (!await AuthorizeAsync(serviceProvider, httpContext, workflow, bookmarkPayload, cancellationToken))
{
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
var workflowRunner = serviceProvider.GetRequiredService<IWorkflowRunner>();
var result = await ExecuteWithinTimeoutAsync(async ct =>
{
if (workflowInstance == null)
return await workflowRunner.RunAsync(workflowGraph, workflowOptions, ct);
return await workflowRunner.RunAsync(workflow, workflowInstance.WorkflowState, workflowOptions, ct);
}, bookmarkPayload.RequestTimeout, httpContext);
await HandleWorkflowFaultAsync(serviceProvider, httpContext, result, cancellationToken);
}
private async Task<T> ExecuteWithinTimeoutAsync<T>(Func<CancellationToken, Task<T>> action, TimeSpan? requestTimeout, HttpContext httpContext)
{
// If no request timeout is specified, execute the action without any timeout.
if (requestTimeout == null)
return await action(httpContext.RequestAborted);
// Create a combined cancellation token that cancels when the request is aborted or when the request timeout is reached.
using var requestTimeoutCancellationTokenSource = new CancellationTokenSource();
requestTimeoutCancellationTokenSource.CancelAfter(requestTimeout.Value);
using var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted, requestTimeoutCancellationTokenSource.Token);
var originalCancellationToken = httpContext.RequestAborted;
// Replace the original cancellation token with the combined one.
httpContext.RequestAborted = combinedTokenSource.Token;
// Execute the action.
var result = await action(httpContext.RequestAborted);
// Restore the original cancellation token.
httpContext.RequestAborted = originalCancellationToken;
return result;
}
private HttpRouteData GetMatchingRoute(IServiceProvider serviceProvider, string path)
{
var routeMatcher = serviceProvider.GetRequiredService<IRouteMatcher>();
var routeTable = serviceProvider.GetRequiredService<IRouteTable>();
var matchingRouteQuery =
from routeData in routeTable
let routeValues = routeMatcher.Match(routeData.Route, path)
where routeValues != null
select new
{
route = routeData,
routeValues
};
var matchingRoute = matchingRouteQuery.FirstOrDefault();
var routeTemplate = matchingRoute?.route ?? new HttpRouteData(path);
return routeTemplate;
}
private async Task<string?> GetCorrelationIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, CancellationToken cancellationToken)
{
var correlationIdSelectors = serviceProvider.GetServices<IHttpCorrelationIdSelector>();
var correlationId = default(string);
foreach (var selector in correlationIdSelectors.OrderByDescending(x => x.Priority))
{
correlationId = await selector.GetCorrelationIdAsync(httpContext, cancellationToken);
if (correlationId != null)
break;
}
return correlationId;
}
private async Task<string?> GetWorkflowInstanceIdAsync(IServiceProvider serviceProvider, HttpContext httpContext, CancellationToken cancellationToken)
{
var workflowInstanceIdSelectors = serviceProvider.GetServices<IHttpWorkflowInstanceIdSelector>();
var workflowInstanceId = default(string);
foreach (var selector in workflowInstanceIdSelectors.OrderByDescending(x => x.Priority))
{
workflowInstanceId = await selector.GetWorkflowInstanceIdAsync(httpContext, cancellationToken);
if (workflowInstanceId != null)
break;
}
return workflowInstanceId;
}
[RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
private static async Task WriteResponseAsync(HttpContext httpContext, CancellationToken cancellationToken)
{
var response = httpContext.Response;
if (!response.HasStarted)
{
response.ContentType = MediaTypeNames.Application.Json;
response.StatusCode = StatusCodes.Status200OK;
var model = new
{
workflowInstanceIds = Array.Empty<string>(),
};
var json = JsonSerializer.Serialize(model);
await response.WriteAsync(json, cancellationToken);
}
}
private string GetPath(HttpContext httpContext) => httpContext.Request.Path.Value!.NormalizeRoute();
[RequiresUnreferencedCode("Calls System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)")]
private async Task<bool> HandleMultipleWorkflowsFoundAsync(HttpContext httpContext, Func<IEnumerable<object>> workflowMatches, CancellationToken cancellationToken)
{
httpContext.Response.ContentType = "application/json";
httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var responseContent = JsonSerializer.Serialize(new
{
errorMessage = "The call is ambiguous and matches multiple workflows.",
workflows = workflowMatches().ToArray()
});
await httpContext.Response.WriteAsync(responseContent, cancellationToken);
return true;
}
private async Task<bool> HandleWorkflowFaultAsync(IServiceProvider serviceProvider, HttpContext httpContext, RunWorkflowResult workflowExecutionResult, CancellationToken cancellationToken)
{
if (!workflowExecutionResult.WorkflowState.Incidents.Any() || httpContext.Response.HasStarted)
return false;
var httpEndpointFaultHandler = serviceProvider.GetRequiredService<IHttpEndpointFaultHandler>();
var workflowInstanceManager = serviceProvider.GetRequiredService<IWorkflowInstanceManager>();
var workflowState = (await workflowInstanceManager.FindByIdAsync(workflowExecutionResult.WorkflowState.Id, cancellationToken))!;
await httpEndpointFaultHandler.HandleAsync(new HttpEndpointFaultContext(httpContext, workflowState.WorkflowState, cancellationToken));
return true;
}
private async Task<bool> AuthorizeAsync(
IServiceProvider serviceProvider,
HttpContext httpContext,
Workflow workflow,
HttpEndpointBookmarkPayload bookmarkPayload,
CancellationToken cancellationToken)
{
var httpEndpointAuthorizationHandler = serviceProvider.GetRequiredService<IHttpEndpointAuthorizationHandler>();
if (bookmarkPayload.Authorize == false)
return true;
return await httpEndpointAuthorizationHandler.AuthorizeAsync(new AuthorizeHttpEndpointContext(httpContext, workflow, bookmarkPayload.Policy));
}
private string ComputeBookmarkHash(IServiceProvider serviceProvider, string path, string method)
{
var bookmarkPayload = new HttpEndpointBookmarkPayload(path, method);
var bookmarkHasher = serviceProvider.GetRequiredService<IStimulusHasher>();
var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<HttpEndpoint>();
return bookmarkHasher.Hash(activityTypeName, bookmarkPayload);
}
}