Whitelist workflow timestamp filter columns
This commit is contained in:
parent
e2e00ff235
commit
37de3404cd
|
|
@ -1,7 +1,9 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Alterations.Core.Contracts;
|
||||
using Elsa.Alterations.Core.Models;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elsa.Alterations.Endpoints.Alterations.DryRun;
|
||||
|
||||
|
|
@ -21,8 +23,25 @@ public class DryRun(IWorkflowInstanceFinder workflowInstanceFinder) : ElsaEndpoi
|
|||
/// <inheritdoc />
|
||||
public override async Task HandleAsync(AlterationWorkflowInstanceFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ValidateInput(filter))
|
||||
{
|
||||
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var workflowInstanceIds = await workflowInstanceFinder.FindAsync(filter, cancellationToken);
|
||||
var response = new Response(workflowInstanceIds.ToList());
|
||||
await Send.OkAsync(response, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateInput(AlterationWorkflowInstanceFilter filter)
|
||||
{
|
||||
foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(filter.TimestampFilters))
|
||||
{
|
||||
AddError(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ using Elsa.Alterations.Core.Contracts;
|
|||
using Elsa.Alterations.Core.Models;
|
||||
using Elsa.Common;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elsa.Alterations.Endpoints.Alterations.Submit;
|
||||
|
||||
|
|
@ -35,6 +37,12 @@ public class Submit : ElsaEndpoint<AlterationPlanParams, Response>
|
|||
/// <inheritdoc />
|
||||
public override async Task HandleAsync(AlterationPlanParams planParams, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ValidateInput(planParams))
|
||||
{
|
||||
await Send.ErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit the plan.
|
||||
var planId = await _alterationPlanScheduler.SubmitAsync(planParams, cancellationToken);
|
||||
|
||||
|
|
@ -42,4 +50,15 @@ public class Submit : ElsaEndpoint<AlterationPlanParams, Response>
|
|||
var response = new Response(planId);
|
||||
await Send.OkAsync(response, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateInput(AlterationPlanParams planParams)
|
||||
{
|
||||
foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(planParams.Filter.TimestampFilters))
|
||||
{
|
||||
AddError(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,27 +84,10 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint<Request, Respon
|
|||
return false;
|
||||
}
|
||||
|
||||
var columnWhitelist = new[]
|
||||
foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(request.TimestampFilters))
|
||||
{
|
||||
"CreatedAt", "UpdatedAt", "FinishedAt"
|
||||
};
|
||||
|
||||
if (request.TimestampFilters?.Any() == true)
|
||||
{
|
||||
foreach (var timestampFilter in request.TimestampFilters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timestampFilter.Column))
|
||||
{
|
||||
AddError("Column must be specified.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!columnWhitelist.Contains(timestampFilter.Column))
|
||||
{
|
||||
AddError($"Invalid column '{timestampFilter.Column}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
AddError(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -159,4 +142,4 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint<Request, Respon
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,18 @@ namespace Elsa.Workflows.Management.Filters;
|
|||
/// </summary>
|
||||
public class WorkflowInstanceFilter
|
||||
{
|
||||
private static readonly string[] TimestampFilterColumns =
|
||||
[
|
||||
nameof(WorkflowInstance.CreatedAt),
|
||||
nameof(WorkflowInstance.UpdatedAt),
|
||||
nameof(WorkflowInstance.FinishedAt)
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The workflow instance timestamp columns that can be used by <see cref="TimestampFilters"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> AllowedTimestampFilterColumns { get; } = Array.AsReadOnly(TimestampFilterColumns);
|
||||
|
||||
/// <summary>
|
||||
/// Filter workflow instances by ID.
|
||||
/// </summary>
|
||||
|
|
@ -154,7 +166,7 @@ public class WorkflowInstanceFilter
|
|||
{
|
||||
foreach (var timestampFilter in TimestampFilters)
|
||||
{
|
||||
var column = timestampFilter.Column;
|
||||
var column = NormalizeTimestampFilterColumn(timestampFilter.Column);
|
||||
var timestamp = timestampFilter.Timestamp;
|
||||
var isZeroTime = timestamp.TimeOfDay == TimeSpan.Zero;
|
||||
var startDay = new DateTimeOffset(timestamp.Date);
|
||||
|
|
@ -194,4 +206,51 @@ public class WorkflowInstanceFilter
|
|||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates timestamp filters.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> ValidateTimestampFilters(IEnumerable<TimestampFilter>? timestampFilters)
|
||||
{
|
||||
if (timestampFilters == null)
|
||||
yield break;
|
||||
|
||||
foreach (var timestampFilter in timestampFilters)
|
||||
{
|
||||
if (!TryNormalizeTimestampFilterColumn(timestampFilter.Column, out _, out var error))
|
||||
yield return error;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a timestamp filter column to its canonical workflow instance property name.
|
||||
/// </summary>
|
||||
public static bool TryNormalizeTimestampFilterColumn(string? column, [NotNullWhen(true)] out string? normalizedColumn, [NotNullWhen(false)] out string? error)
|
||||
{
|
||||
normalizedColumn = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(column))
|
||||
{
|
||||
error = "Timestamp filter column must be specified.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmedColumn = column.Trim();
|
||||
normalizedColumn = TimestampFilterColumns.FirstOrDefault(x => string.Equals(x, trimmedColumn, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (normalizedColumn != null)
|
||||
return true;
|
||||
|
||||
error = $"Invalid timestamp filter column '{column}'. Allowed columns are: {string.Join(", ", TimestampFilterColumns)}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeTimestampFilterColumn(string? column)
|
||||
{
|
||||
if (TryNormalizeTimestampFilterColumn(column, out var normalizedColumn, out var error))
|
||||
return normalizedColumn;
|
||||
|
||||
throw new ArgumentException(error, nameof(TimestampFilters));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Elsa.Alterations.Core.Contracts;
|
||||
using Elsa.Alterations.Endpoints.Alterations.DryRun;
|
||||
using Elsa.Common;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Enums;
|
||||
using Elsa.Workflows.Runtime;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using SubmitEndpoint = Elsa.Alterations.Endpoints.Alterations.Submit.Submit;
|
||||
|
||||
namespace Elsa.Alterations.IntegrationTests;
|
||||
|
||||
public class AlterationsApiTimestampFilterTests : IAsyncLifetime
|
||||
{
|
||||
private WebApplication? _app;
|
||||
private bool _wasSecurityEnabled;
|
||||
private HttpClient _httpClient = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
|
||||
EndpointSecurityOptions.SecurityIsEnabled = false;
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Services.AddFastEndpoints(o =>
|
||||
{
|
||||
o.Assemblies = [typeof(DryRun).Assembly];
|
||||
o.Filter = endpointType => endpointType == typeof(DryRun) || endpointType == typeof(SubmitEndpoint);
|
||||
});
|
||||
builder.Services.AddSingleton(Substitute.For<IWorkflowInstanceFinder>());
|
||||
builder.Services.AddSingleton(Substitute.For<IAlterationPlanScheduler>());
|
||||
builder.Services.AddSingleton(Substitute.For<IAlterationRunner>());
|
||||
builder.Services.AddSingleton(Substitute.For<IAlteredWorkflowDispatcher>());
|
||||
builder.Services.AddSingleton(Substitute.For<IAlterationPlanStore>());
|
||||
builder.Services.AddSingleton(Substitute.For<IAlterationJobStore>());
|
||||
builder.Services.AddSingleton(Substitute.For<IWorkflowDispatcher>());
|
||||
builder.Services.AddSingleton(Substitute.For<IWorkflowInstanceStore>());
|
||||
builder.Services.AddSingleton(Substitute.For<IIdentityGenerator>());
|
||||
builder.Services.AddSingleton(Substitute.For<ISystemClock>());
|
||||
builder.Services.AddLogging();
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseFastEndpoints();
|
||||
|
||||
await _app.StartAsync();
|
||||
_httpClient = _app.GetTestClient();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
|
||||
_httpClient.Dispose();
|
||||
|
||||
if (_app == null)
|
||||
return;
|
||||
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/alterations/dry-run")]
|
||||
[InlineData("/alterations/submit")]
|
||||
public async Task Post_WithInjectedTimestampFilterColumn_ReturnsBadRequest(string path)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync(path, CreateRequest(path));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("Invalid timestamp filter column", body);
|
||||
}
|
||||
|
||||
private static object CreateRequest(string path)
|
||||
{
|
||||
var filter = new
|
||||
{
|
||||
timestampFilters = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
column = "CreatedAt == @0 || Id != null",
|
||||
@operator = TimestampFilterOperator.Is,
|
||||
timestamp = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return path == "/alterations/submit"
|
||||
? new { filter }
|
||||
: filter;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
using Elsa.Alterations.Core.Contracts;
|
||||
using Elsa.Alterations.Core.Models;
|
||||
using Elsa.Alterations.Extensions;
|
||||
using Elsa.Testing.Shared;
|
||||
using Elsa.Workflows;
|
||||
using Elsa.Workflows.Management;
|
||||
using Elsa.Workflows.Management.Entities;
|
||||
using Elsa.Workflows.Management.Enums;
|
||||
using Elsa.Workflows.Management.Models;
|
||||
using Elsa.Workflows.State;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Elsa.Alterations.IntegrationTests;
|
||||
|
||||
public class WorkflowInstanceFinderTimestampFilterTests
|
||||
{
|
||||
private readonly IWorkflowInstanceFinder _workflowInstanceFinder;
|
||||
private readonly IWorkflowInstanceStore _workflowInstanceStore;
|
||||
|
||||
public WorkflowInstanceFinderTimestampFilterTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
var services = new TestApplicationBuilder(testOutputHelper)
|
||||
.ConfigureElsa(elsa => elsa.UseAlterations())
|
||||
.Build();
|
||||
|
||||
_workflowInstanceFinder = services.GetRequiredService<IWorkflowInstanceFinder>();
|
||||
_workflowInstanceStore = services.GetRequiredService<IWorkflowInstanceStore>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindAsync_WithAllowedTimestampColumn_FiltersWorkflowInstances()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
await _workflowInstanceStore.AddAsync(CreateWorkflowInstance("matching", timestamp));
|
||||
await _workflowInstanceStore.AddAsync(CreateWorkflowInstance("older", timestamp.AddDays(-1)));
|
||||
|
||||
var result = await _workflowInstanceFinder.FindAsync(new()
|
||||
{
|
||||
TimestampFilters =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Column = nameof(WorkflowInstance.CreatedAt),
|
||||
Operator = TimestampFilterOperator.GreaterThanOrEqual,
|
||||
Timestamp = timestamp
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
var workflowInstanceId = Assert.Single(result);
|
||||
Assert.Equal("matching", workflowInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindAsync_WithInjectedTimestampColumn_RejectsColumn()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
await _workflowInstanceStore.AddAsync(CreateWorkflowInstance("matching", timestamp));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _workflowInstanceFinder.FindAsync(new()
|
||||
{
|
||||
TimestampFilters =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Column = "CreatedAt == @0 || Id != null",
|
||||
Operator = TimestampFilterOperator.Is,
|
||||
Timestamp = timestamp
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
Assert.Contains("Invalid timestamp filter column", exception.Message);
|
||||
}
|
||||
|
||||
private static WorkflowInstance CreateWorkflowInstance(string id, DateTimeOffset createdAt)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = id,
|
||||
DefinitionId = "definition",
|
||||
DefinitionVersionId = "definition-version",
|
||||
Version = 1,
|
||||
WorkflowState = new WorkflowState
|
||||
{
|
||||
Id = id,
|
||||
DefinitionId = "definition",
|
||||
DefinitionVersionId = "definition-version",
|
||||
DefinitionVersion = 1,
|
||||
Status = WorkflowStatus.Running,
|
||||
SubStatus = WorkflowSubStatus.Suspended,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = createdAt
|
||||
},
|
||||
Status = WorkflowStatus.Running,
|
||||
SubStatus = WorkflowSubStatus.Suspended,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = createdAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
using Elsa.Workflows.Management.Entities;
|
||||
using Elsa.Workflows.Management.Enums;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Management.Models;
|
||||
using Elsa.Workflows.State;
|
||||
|
||||
namespace Elsa.Workflows.Management.UnitTests.Filters;
|
||||
|
||||
public class WorkflowInstanceFilterTimestampTests
|
||||
{
|
||||
private readonly DateTimeOffset _matchingCreatedAt = new(2026, 5, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
private readonly DateTimeOffset _matchingUpdatedAt = new(2026, 5, 20, 11, 0, 0, TimeSpan.Zero);
|
||||
private readonly DateTimeOffset _matchingFinishedAt = new(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
|
||||
private readonly IQueryable<WorkflowInstance> _workflowInstances;
|
||||
|
||||
public WorkflowInstanceFilterTimestampTests()
|
||||
{
|
||||
_workflowInstances = new[]
|
||||
{
|
||||
CreateWorkflowInstance("matching", _matchingCreatedAt, _matchingUpdatedAt, _matchingFinishedAt),
|
||||
CreateWorkflowInstance("older", _matchingCreatedAt.AddDays(-1), _matchingUpdatedAt.AddDays(-1), _matchingFinishedAt.AddDays(-1)),
|
||||
CreateWorkflowInstance("unfinished", _matchingCreatedAt.AddDays(-1), _matchingUpdatedAt.AddDays(-1), null)
|
||||
}.AsQueryable();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(WorkflowInstance.CreatedAt))]
|
||||
[InlineData(nameof(WorkflowInstance.UpdatedAt))]
|
||||
[InlineData(nameof(WorkflowInstance.FinishedAt))]
|
||||
public void Apply_WithAllowedTimestampColumn_FiltersByTimestamp(string column)
|
||||
{
|
||||
var filter = new WorkflowInstanceFilter
|
||||
{
|
||||
TimestampFilters =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Column = column,
|
||||
Operator = TimestampFilterOperator.GreaterThanOrEqual,
|
||||
Timestamp = GetMatchingTimestamp(column)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var result = filter.Apply(_workflowInstances).ToList();
|
||||
|
||||
var workflowInstance = Assert.Single(result);
|
||||
Assert.Equal("matching", workflowInstance.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithInjectedTimestampColumn_RejectsColumnBeforeDynamicLinqParsesIt()
|
||||
{
|
||||
var filter = new WorkflowInstanceFilter
|
||||
{
|
||||
TimestampFilters =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Column = "CreatedAt == @0 || Id != null",
|
||||
Operator = TimestampFilterOperator.Is,
|
||||
Timestamp = _matchingCreatedAt
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<ArgumentException>(() => filter.Apply(_workflowInstances).ToList());
|
||||
|
||||
Assert.Contains("Invalid timestamp filter column", exception.Message);
|
||||
Assert.Contains("CreatedAt, UpdatedAt, FinishedAt", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTimestampFilters_WithMissingColumn_ReturnsClearValidationError()
|
||||
{
|
||||
var errors = WorkflowInstanceFilter.ValidateTimestampFilters(
|
||||
[
|
||||
new()
|
||||
{
|
||||
Column = " ",
|
||||
Operator = TimestampFilterOperator.Is,
|
||||
Timestamp = _matchingCreatedAt
|
||||
}
|
||||
]).ToList();
|
||||
|
||||
var error = Assert.Single(errors);
|
||||
Assert.Equal("Timestamp filter column must be specified.", error);
|
||||
}
|
||||
|
||||
private DateTimeOffset GetMatchingTimestamp(string column) => column switch
|
||||
{
|
||||
nameof(WorkflowInstance.CreatedAt) => _matchingCreatedAt,
|
||||
nameof(WorkflowInstance.UpdatedAt) => _matchingUpdatedAt,
|
||||
nameof(WorkflowInstance.FinishedAt) => _matchingFinishedAt,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(column), column, null)
|
||||
};
|
||||
|
||||
private static WorkflowInstance CreateWorkflowInstance(string id, DateTimeOffset createdAt, DateTimeOffset updatedAt, DateTimeOffset? finishedAt)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Id = id,
|
||||
DefinitionId = "definition",
|
||||
DefinitionVersionId = "definition-version",
|
||||
Version = 1,
|
||||
WorkflowState = new WorkflowState
|
||||
{
|
||||
Id = id,
|
||||
DefinitionId = "definition",
|
||||
DefinitionVersionId = "definition-version",
|
||||
DefinitionVersion = 1,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = updatedAt,
|
||||
FinishedAt = finishedAt
|
||||
},
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = updatedAt,
|
||||
FinishedAt = finishedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue