Port HTTP endpoint handlers for auth and faults

This commit is contained in:
Sipke Schoorstra 2022-03-10 00:19:00 +01:00
parent bd48f113cc
commit 87f836b0e3
25 changed files with 294 additions and 21 deletions

View file

@ -54,7 +54,7 @@ public class Inline<T> : Activity<T>
public Inline(Func<ActivityExecutionContext, ValueTask<T>> activity, RegisterLocationReference? output = default)
{
_activity = activity;
if (output != null) Result = new Output<T>(output);
if (output != null) Result = new Output<T?>(output);
}
public Inline(Func<ValueTask<T>> activity, RegisterLocationReference? output = default) : this(_ => activity(), output)

View file

@ -37,7 +37,7 @@ public static class ActivityExtensions
/// <summary>
/// Creates an input from the activity's result.
/// </summary>
public static Input<T> CreateInput<T>(this Activity<T> activity) => activity.Result.CreateInput();
public static Input<T?> CreateInput<T>(this Activity<T> activity) => activity.Result.CreateInput();
public static IEnumerable<Variable> GetVariables(this IActivity activity)
{

View file

@ -23,7 +23,7 @@ public static class DictionaryExtensions
return ConvertValue<T>(dictionary[key]);
}
public static T GetOrAdd<T>(this IDictionary<string, object?> dictionary, string key, Func<T> valueFactory) where T : notnull
public static T? GetOrAdd<T>(this IDictionary<string, object?> dictionary, string key, Func<T> valueFactory) where T : notnull
{
if (dictionary.TryGetValue<T>(key, out var value))
return value;

View file

@ -33,10 +33,26 @@ public abstract class Activity : IActivity
public abstract class ActivityWithResult : Activity
{
protected ActivityWithResult()
{
}
protected ActivityWithResult(string activityType) : base(activityType)
{
}
public Output? Result { get; set; }
}
public abstract class Activity<T> : ActivityWithResult
{
public new Output<T>? Result { get; set; }
protected Activity() : base()
{
}
protected Activity(string activityType) : base(activityType)
{
}
public new Output<T?>? Result { get; set; }
}

View file

@ -6,7 +6,13 @@ public class ExpressionExecutionContext
{
private readonly IServiceProvider _serviceProvider;
public ExpressionExecutionContext(IServiceProvider serviceProvider, Register register, Workflow workflow, IDictionary<string, object?> transientProperties, ExpressionExecutionContext? parentContext, CancellationToken cancellationToken)
public ExpressionExecutionContext(
IServiceProvider serviceProvider,
Register register,
Workflow workflow,
IDictionary<string, object?> transientProperties,
ExpressionExecutionContext? parentContext,
CancellationToken cancellationToken)
{
_serviceProvider = serviceProvider;
Register = register;
@ -30,7 +36,6 @@ public class ExpressionExecutionContext
public object? Get(Output? output) => output != null ? GetLocation(output.LocationReference).Value : default;
public T? GetVariable<T>(string name) => (T?)GetVariable(name);
public T? GetVariable<T>() => (T?)GetVariable(typeof(T).Name);
public object? GetVariable(string name) => new Variable(name).Get(this);
public Variable SetVariable<T>(T? value) => SetVariable(typeof(T).Name, value);

View file

@ -28,6 +28,38 @@ public abstract class Trigger : Activity, ITrigger
/// </summary>
protected virtual IEnumerable<object> GetTriggerData(TriggerIndexingContext context) => new[]{ GetTriggerDatum(context) };
/// <summary>
/// Override this method to return a trigger datum.
/// </summary>
protected virtual object GetTriggerDatum(TriggerIndexingContext context) => new();
}
public abstract class Trigger<T> : Activity<T>, ITrigger
{
protected Trigger()
{
}
protected Trigger(string activityType) : base(activityType)
{
}
ValueTask<IEnumerable<object>> ITrigger.GetTriggerDataAsync(TriggerIndexingContext context) => GetTriggerDataAsync(context);
/// <summary>
/// Override this method to return trigger data.
/// </summary>
protected virtual ValueTask<IEnumerable<object>> GetTriggerDataAsync(TriggerIndexingContext context)
{
var hashes = GetTriggerData(context);
return ValueTask.FromResult(hashes);
}
/// <summary>
/// Override this method to return trigger data.
/// </summary>
protected virtual IEnumerable<object> GetTriggerData(TriggerIndexingContext context) => new[]{ GetTriggerDatum(context) };
/// <summary>
/// Override this method to return a trigger datum.
/// </summary>

View file

@ -9,11 +9,11 @@ using Elsa.Modules.Http.Models;
namespace Elsa.Modules.Http;
[Activity("Elsa.Http.HttpEndpoint", "Waits for an inbound HTTP request that matches the specified path and methods", "HTTP")]
public class HttpEndpoint : Trigger
[Activity("Http", "Waits for an inbound HTTP request that matches the specified path and methods", category: "HTTP")]
public class HttpEndpoint : Trigger<HttpRequestModel>
{
public const string InputKey = "HttpRequest";
[Input] public Input<string> Path { get; set; } = default!;
[Input(
@ -22,21 +22,32 @@ public class HttpEndpoint : Trigger
)]
public Input<ICollection<string>> SupportedMethods { get; set; } = new(new[] { HttpMethod.Get.Method });
[Output] public Output<HttpRequestModel>? Request { get; set; }
[Input(
Description = "Allow authenticated requests only",
Category = "Security"
)]
public Input<bool> Authorize { get; set; } = new(false);
[Input(
Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.",
Category = "Security"
)]
public Input<string?> Policy { get; set; } = new(default(string?));
protected override IEnumerable<object> GetTriggerData(TriggerIndexingContext context) => GetBookmarkData(context.ExpressionExecutionContext);
protected override void Execute(ActivityExecutionContext context)
{
// If we did not receive external input, it means we are just now encountering this activity.
if (!context.TryGetInput<HttpRequestModel>(InputKey, out var request ))
if (!context.TryGetInput<HttpRequestModel>(InputKey, out var request))
{
// Create bookmarks for when we receive the expected HTTP request.
context.CreateBookmarks(GetBookmarkData(context.ExpressionExecutionContext));
return;
}
// Provide the received HTTP request as output.
context.Set(Request, request);
context.Set(Result, request);
}
private IEnumerable<object> GetBookmarkData(ExpressionExecutionContext context)

View file

@ -0,0 +1,10 @@
using System.Threading.Tasks;
using Elsa.Modules.Http.Models;
namespace Elsa.Modules.Http.Contracts
{
public interface IHttpEndpointAuthorizationHandler
{
ValueTask<bool> AuthorizeAsync(AuthorizeHttpEndpointContext context);
}
}

View file

@ -0,0 +1,12 @@
using System.Threading.Tasks;
using Elsa.Modules.Http.Models;
namespace Elsa.Modules.Http.Contracts;
/// <summary>
/// Implement this to control what to return to the client in case an unhandled exception occurs while executing the workflow.
/// </summary>
public interface IHttpEndpointWorkflowFaultHandler
{
ValueTask HandleAsync(HttpEndpointFaultedWorkflowContext context);
}

View file

@ -7,13 +7,15 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\Elsa.Core\Elsa.Core.csproj" />
<ProjectReference Include="..\..\runtime\Elsa.Runtime\Elsa.Runtime.csproj" />
<ProjectReference Include="..\..\core\Elsa.Core\Elsa.Core.csproj" />
<ProjectReference Include="..\..\runtime\Elsa.Runtime\Elsa.Runtime.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,11 @@
using System.Threading.Tasks;
using Elsa.Modules.Http.Contracts;
using Elsa.Modules.Http.Models;
namespace Elsa.Modules.Http.Handlers
{
public class AllowAnonymousHttpEndpointAuthorizationHandler : IHttpEndpointAuthorizationHandler
{
public ValueTask<bool> AuthorizeAsync(AuthorizeHttpEndpointContext context) => new(true);
}
}

View file

@ -0,0 +1,38 @@
using System.Threading.Tasks;
using Elsa.Extensions;
using Elsa.Modules.Http.Contracts;
using Elsa.Modules.Http.Models;
using Microsoft.AspNetCore.Authorization;
namespace Elsa.Modules.Http.Handlers
{
public class AuthenticationBasedHttpEndpointAuthorizationHandler : IHttpEndpointAuthorizationHandler
{
private readonly IAuthorizationService _authorizationService;
public AuthenticationBasedHttpEndpointAuthorizationHandler(IAuthorizationService authorizationService) => _authorizationService = authorizationService;
public async ValueTask<bool> AuthorizeAsync(AuthorizeHttpEndpointContext context)
{
var httpContext = context.HttpContext;
var user = httpContext.User;
var identity = user.Identity;
if (identity == null)
return false;
if (identity.IsAuthenticated == false)
return false;
var httpEndpoint = context.Activity;
var expressionExecutionContext = context.ExpressionExecutionContext;
var policyName = httpEndpoint.Policy.Get(expressionExecutionContext);
if (string.IsNullOrWhiteSpace(policyName))
return identity.IsAuthenticated;
var resource = new HttpWorkflowResource(expressionExecutionContext, httpEndpoint, context.WorkflowInstanceId);
var authorizationResult = await _authorizationService.AuthorizeAsync(user, resource, policyName);
return authorizationResult.Succeeded;
}
}
}

View file

@ -0,0 +1,34 @@
using System.Net.Mime;
using System.Text.Json;
using System.Threading.Tasks;
using Elsa.Modules.Http.Contracts;
using Elsa.Modules.Http.Models;
using Microsoft.AspNetCore.Http;
namespace Elsa.Modules.Http.Handlers;
public class DefaultHttpEndpointWorkflowFaultHandler : IHttpEndpointWorkflowFaultHandler
{
public virtual async ValueTask HandleAsync(HttpEndpointFaultedWorkflowContext context)
{
var httpContext = context.HttpContext;
var workflowInstance = context.WorkflowInstance;
httpContext.Response.ContentType = MediaTypeNames.Application.Json;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
var faultedResponse = JsonSerializer.Serialize(new
{
errorMessage = $"Workflow faulted at {workflowInstance.FaultedAt!} with error: {workflowInstance.Fault!.Message}",
exception = workflowInstance.Fault?.Exception,
workflow = new
{
name = workflowInstance.Name,
version = workflowInstance.Version,
instanceId = workflowInstance.Id
}
});
await httpContext.Response.WriteAsync(faultedResponse, context.CancellationToken);
}
}

View file

@ -0,0 +1,8 @@
using System.Threading;
using Elsa.Models;
using Microsoft.AspNetCore.Http;
namespace Elsa.Modules.Http.Models
{
public record AuthorizeHttpEndpointContext(ExpressionExecutionContext ExpressionExecutionContext, HttpContext HttpContext, HttpEndpoint Activity, string WorkflowInstanceId);
}

View file

@ -0,0 +1,8 @@
using System;
using System.Threading;
using Elsa.Persistence.Entities;
using Microsoft.AspNetCore.Http;
namespace Elsa.Modules.Http.Models;
public record HttpEndpointFaultedWorkflowContext(HttpContext HttpContext, WorkflowInstance WorkflowInstance, Exception? Exception, CancellationToken CancellationToken);

View file

@ -0,0 +1,6 @@
using Elsa.Models;
namespace Elsa.Modules.Http.Models
{
public record HttpWorkflowResource(ExpressionExecutionContext ExpressionExecutionContext, HttpEndpoint Activity, string WorkflowInstance);
}

View file

@ -0,0 +1,19 @@
using System;
using Elsa.Modules.Http.Contracts;
using Elsa.Modules.Http.Handlers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Modules.Http.Options
{
public class HttpActivityOptions
{
/// <summary>
/// The root path at which HTTP activities can be invoked.
/// </summary>
public PathString? BasePath { get; set; }
public Func<IServiceProvider, IHttpEndpointAuthorizationHandler> HttpEndpointAuthorizationHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance<AllowAnonymousHttpEndpointAuthorizationHandler>;
public Func<IServiceProvider, IHttpEndpointWorkflowFaultHandler> HttpEndpointWorkflowFaultHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance<DefaultHttpEndpointWorkflowFaultHandler>;
}
}

View file

@ -1,3 +1,4 @@
using Elsa.Persistence.Models;
using Elsa.State;
namespace Elsa.Persistence.Entities;
@ -11,6 +12,7 @@ public class WorkflowInstance : Entity
public WorkflowStatus WorkflowStatus { get; set; }
public string CorrelationId { get; init; } = default!;
public string? Name { get; set; }
public WorkflowFault? Fault { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastExecutedAt { get; set; }
public DateTimeOffset? FinishedAt { get; set; }

View file

@ -0,0 +1,36 @@
using System.Collections;
namespace Elsa.Persistence.Models
{
public class SimpleException
{
public SimpleException(Type type, string message, string? stackTrace, IDictionary data, SimpleException? innerException = default)
{
Type = type;
Message = message;
StackTrace = stackTrace;
InnerException = innerException;
Data = data;
}
public Type Type { get; set; }
public string Message { get; set; }
public string? StackTrace { get; set; }
public SimpleException? InnerException { get; set; }
public IDictionary Data { get; set; }
public static SimpleException? FromException(Exception? ex)
{
if (ex == null)
return null;
var exceptionType = ex.GetType();
var simpleException = new SimpleException(exceptionType, ex.Message, ex.StackTrace, ex.Data);
if (ex.InnerException != null)
simpleException.InnerException = FromException(ex.InnerException);
return simpleException;
}
}
}

View file

@ -0,0 +1,14 @@
namespace Elsa.Persistence.Models
{
public class SimpleExceptionProperty
{
public SimpleExceptionProperty(string name, object value)
{
Name = name;
Value = value;
}
public string Name { get; set; }
public object Value { get; set; }
}
}

View file

@ -0,0 +1,4 @@
namespace Elsa.Persistence.Models
{
public record WorkflowFault(SimpleException? Exception, string Message, string? FaultedActivityId, object? ActivityInput, bool Resuming);
}

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Elsa.Persistence.EntityFrameworkCore.Sqlite.Migrations
{
[DbContext(typeof(ElsaDbContext))]
[Migration("20220308133708_Initial")]
[Migration("20220309231832_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)

View file

@ -9,6 +9,7 @@ namespace Elsa.Persistence.EntityFrameworkCore.Configuration
public void Configure(EntityTypeBuilder<WorkflowInstance> builder)
{
builder.Ignore(x => x.WorkflowState);
builder.Ignore(x => x.Fault);
builder.Property<string>("Data");
builder.HasIndex(x => new { x.WorkflowStatus, x.DefinitionId, x.Version }).HasDatabaseName($"IX_{nameof(WorkflowInstance)}_{nameof(WorkflowInstance.WorkflowStatus)}_{nameof(WorkflowInstance.DefinitionId)}_{nameof(WorkflowInstance.Version)}");
builder.HasIndex(x => new { x.WorkflowStatus, x.DefinitionId }).HasDatabaseName($"IX_{nameof(WorkflowInstance)}_{nameof(WorkflowInstance.WorkflowStatus)}_{nameof(WorkflowInstance.DefinitionId)}");

View file

@ -2,6 +2,7 @@ using System.Text.Json;
using Elsa.Management.Serialization;
using Elsa.Persistence.Entities;
using Elsa.Persistence.EntityFrameworkCore.Contracts;
using Elsa.Persistence.Models;
using Elsa.State;
namespace Elsa.Persistence.EntityFrameworkCore.Handlers.Serialization;
@ -17,7 +18,7 @@ public class WorkflowInstanceSerializer : IEntitySerializer<WorkflowInstance>
public void Serialize(ElsaDbContext dbContext, WorkflowInstance entity)
{
var data = new WorkflowInstanceState(entity.WorkflowState);
var data = new WorkflowInstanceState(entity.WorkflowState, entity.Fault);
var options = _workflowSerializerOptionsProvider.CreatePersistenceOptions();
var json = JsonSerializer.Serialize(data, options);
@ -26,7 +27,7 @@ public class WorkflowInstanceSerializer : IEntitySerializer<WorkflowInstance>
public void Deserialize(ElsaDbContext dbContext, WorkflowInstance entity)
{
var data = new WorkflowInstanceState(entity.WorkflowState);
var data = new WorkflowInstanceState(entity.WorkflowState, entity.Fault);
var json = (string?)dbContext.Entry(entity).Property("Data").CurrentValue;
if (!string.IsNullOrWhiteSpace(json))
@ -36,6 +37,7 @@ public class WorkflowInstanceSerializer : IEntitySerializer<WorkflowInstance>
}
entity.WorkflowState = data.WorkflowState;
entity.Fault = data.Fault;
}
// Can't use records when using System.Text.Json serialization and reference handling. Hence, using a class with default constructor.
@ -45,11 +47,13 @@ public class WorkflowInstanceSerializer : IEntitySerializer<WorkflowInstance>
{
}
public WorkflowInstanceState(WorkflowState workflowState)
public WorkflowInstanceState(WorkflowState workflowState, WorkflowFault? fault)
{
WorkflowState = workflowState;
Fault = fault;
}
public WorkflowState WorkflowState { get; init; } = default!;
public WorkflowFault? Fault { get; set; }
}
}