diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs
index 4bc173d30..1445600f2 100644
--- a/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs
+++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/DryRun/Endpoint.cs
@@ -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
///
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);
}
-}
\ No newline at end of file
+
+ private bool ValidateInput(AlterationWorkflowInstanceFilter filter)
+ {
+ foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(filter.TimestampFilters))
+ {
+ AddError(error);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs
index e36a910b5..4d7e57d5b 100644
--- a/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs
+++ b/src/modules/Elsa.Alterations/Endpoints/Alterations/Submit/Endpoint.cs
@@ -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
///
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
var response = new Response(planId);
await Send.OkAsync(response, cancellationToken);
}
-}
\ No newline at end of file
+
+ private bool ValidateInput(AlterationPlanParams planParams)
+ {
+ foreach (var error in WorkflowInstanceFilter.ValidateTimestampFilters(planParams.Filter.TimestampFilters))
+ {
+ AddError(error);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs
index c8ad5401b..2ae8ee07c 100644
--- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs
+++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/List/Endpoint.cs
@@ -84,27 +84,10 @@ internal class List(IWorkflowInstanceStore store) : ElsaEndpoint
public class WorkflowInstanceFilter
{
+ private static readonly string[] TimestampFilterColumns =
+ [
+ nameof(WorkflowInstance.CreatedAt),
+ nameof(WorkflowInstance.UpdatedAt),
+ nameof(WorkflowInstance.FinishedAt)
+ ];
+
+ ///
+ /// The workflow instance timestamp columns that can be used by .
+ ///
+ public static IReadOnlyCollection AllowedTimestampFilterColumns { get; } = Array.AsReadOnly(TimestampFilterColumns);
+
///
/// Filter workflow instances by ID.
///
@@ -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;
}
-}
\ No newline at end of file
+
+ ///
+ /// Validates timestamp filters.
+ ///
+ public static IEnumerable ValidateTimestampFilters(IEnumerable? timestampFilters)
+ {
+ if (timestampFilters == null)
+ yield break;
+
+ foreach (var timestampFilter in timestampFilters)
+ {
+ if (!TryNormalizeTimestampFilterColumn(timestampFilter.Column, out _, out var error))
+ yield return error;
+ }
+ }
+
+ ///
+ /// Resolves a timestamp filter column to its canonical workflow instance property name.
+ ///
+ 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));
+ }
+}
diff --git a/test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs b/test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs
new file mode 100644
index 000000000..196dc7939
--- /dev/null
+++ b/test/integration/Elsa.Alterations.IntegrationTests/AlterationsApiTimestampFilterTests.cs
@@ -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());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ builder.Services.AddSingleton(Substitute.For());
+ 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;
+ }
+}
diff --git a/test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs b/test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs
new file mode 100644
index 000000000..85136a420
--- /dev/null
+++ b/test/integration/Elsa.Alterations.IntegrationTests/WorkflowInstanceFinderTimestampFilterTests.cs
@@ -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();
+ _workflowInstanceStore = services.GetRequiredService();
+ }
+
+ [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(() => _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
+ };
+ }
+}
diff --git a/test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs b/test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs
new file mode 100644
index 000000000..9854212e3
--- /dev/null
+++ b/test/unit/Elsa.Workflows.Management.UnitTests/Filters/WorkflowInstanceFilterTimestampTests.cs
@@ -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 _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(() => 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
+ };
+ }
+}