Honor label filters when listing workflow definitions (#8035)

* Honor label filters when listing workflow definitions

* Authorize label lookups and require filter resolution
This commit is contained in:
Sipke Schoorstra 2026-09-05 22:13:16 -07:00 committed by GitHub
parent 5f692b956d
commit cbdc3f7e9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 398 additions and 5 deletions

View file

@ -0,0 +1,14 @@
using Elsa.Labels.Entities;
namespace Elsa.Labels.Contracts;
/// <summary>
/// Queries workflow definition label relationships for workflow-definition filter providers.
/// </summary>
public interface IWorkflowDefinitionLabelQuery
{
/// <summary>
/// Returns workflow definition versions associated with any of the specified label IDs. Matching is based on the workflow definition version ID; an empty or unknown set returns no associations.
/// </summary>
Task<IEnumerable<WorkflowDefinitionLabel>> FindByLabelIdsAsync(IEnumerable<string> labelIds, CancellationToken cancellationToken = default);
}

View file

@ -6,6 +6,7 @@ using Elsa.Features.Services;
using Elsa.Labels.Contracts;
using Elsa.Labels.Entities;
using Elsa.Labels.Services;
using Elsa.Workflows.Management;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Labels.Features;
@ -45,6 +46,7 @@ public class LabelsFeature : FeatureBase
.AddMemoryStore<WorkflowDefinitionLabel, InMemoryWorkflowDefinitionLabelStore>()
.AddScoped(LabelStore)
.AddScoped(WorkflowDefinitionLabelStore)
.AddScoped<IWorkflowDefinitionFilterProvider, WorkflowDefinitionLabelFilterProvider>()
;
Services.AddNotificationHandlersFrom(GetType());

View file

@ -7,7 +7,7 @@ namespace Elsa.Labels.Services;
/// <summary>
/// An in-memory store of workflow-label associations.
/// </summary>
public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore
public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore, IWorkflowDefinitionLabelQuery
{
private readonly MemoryStore<WorkflowDefinitionLabel> _store;
@ -47,6 +47,14 @@ public class InMemoryWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStor
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<IEnumerable<WorkflowDefinitionLabel>> FindByLabelIdsAsync(IEnumerable<string> labelIds, CancellationToken cancellationToken = default)
{
var ids = labelIds.ToHashSet();
var result = _store.FindMany(x => ids.Contains(x.LabelId));
return Task.FromResult(result);
}
/// <inheritdoc />
public Task ReplaceAsync(IEnumerable<WorkflowDefinitionLabel> removed, IEnumerable<WorkflowDefinitionLabel> added, CancellationToken cancellationToken = default)
{

View file

@ -0,0 +1,46 @@
using Elsa.Authorization;
using Elsa.Labels.Contracts;
using Elsa.Labels.Permissions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Exceptions;
using Elsa.Workflows.Management.Filters;
namespace Elsa.Labels.Services;
/// <summary>
/// Applies label relationships to workflow definition filters. A version qualifies when it has any requested label ID; an unknown label produces no matches. A caller filtering by labels must hold the workflow-definition-label view permission. The configured label store must implement <see cref="IWorkflowDefinitionLabelQuery"/> to support this filter.
/// </summary>
public class WorkflowDefinitionLabelFilterProvider(IWorkflowDefinitionLabelStore store) : IWorkflowDefinitionFilterProvider
{
/// <inheritdoc />
public bool CanApply(WorkflowDefinitionFilter filter) => filter.LabelIds is { Count: > 0 };
/// <summary>Requires workflow-definition-label view permission when a label filter is present.</summary>
public IEnumerable<Permission> GetRequiredPermissions(WorkflowDefinitionFilter filter) =>
CanApply(filter) ? [new(LabelPermissions.WorkflowDefinitionLabels, CoreVerbs.View)] : [];
/// <inheritdoc />
public async Task ApplyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
{
if (filter.LabelIds is not { Count: > 0 })
{
return;
}
if (store is not IWorkflowDefinitionLabelQuery query)
{
throw new WorkflowDefinitionFilterNotSupportedException("Filtering workflow definitions by labels is not supported by the configured label store.");
}
var labelIds = filter.LabelIds.Distinct().ToList();
var matchingVersionIds = (await query.FindByLabelIdsAsync(labelIds, cancellationToken))
.Select(x => x.WorkflowDefinitionVersionId)
.Distinct()
.ToList();
filter.Ids = filter.Ids == null
? matchingVersionIds
: filter.Ids.Intersect(matchingVersionIds).ToList();
filter.LabelIds = null;
}
}

View file

@ -5,6 +5,7 @@ using Elsa.Labels.Contracts;
using Elsa.Labels.Entities;
using Elsa.Labels.Services;
using Elsa.Platform.PackageManifest.Generator.Hints;
using Elsa.Workflows.Management;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
@ -37,7 +38,8 @@ public class LabelsFeature : IShellFeature
.AddMemoryStore<Label, InMemoryLabelStore>()
.AddMemoryStore<WorkflowDefinitionLabel, InMemoryWorkflowDefinitionLabelStore>()
.AddScoped(LabelStore)
.AddScoped(WorkflowDefinitionLabelStore);
.AddScoped(WorkflowDefinitionLabelStore)
.AddScoped<IWorkflowDefinitionFilterProvider, WorkflowDefinitionLabelFilterProvider>();
services.AddNotificationHandlersFrom(GetType());
}

View file

@ -4,7 +4,7 @@ using Elsa.Labels.Entities;
namespace Elsa.Persistence.EFCore.Modules.Labels;
/// <inheritdoc />
public class EFCoreWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore
public class EFCoreWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore, IWorkflowDefinitionLabelQuery
{
private readonly EntityStore<LabelsElsaDbContext, WorkflowDefinitionLabel> _store;
@ -26,6 +26,13 @@ public class EFCoreWorkflowDefinitionLabelStore : IWorkflowDefinitionLabelStore
public async Task<IEnumerable<WorkflowDefinitionLabel>> FindByWorkflowDefinitionVersionIdAsync(string workflowDefinitionVersionId, CancellationToken cancellationToken = default) =>
await _store.FindManyAsync(x => x.WorkflowDefinitionVersionId == workflowDefinitionVersionId, cancellationToken);
/// <inheritdoc />
public async Task<IEnumerable<WorkflowDefinitionLabel>> FindByLabelIdsAsync(IEnumerable<string> labelIds, CancellationToken cancellationToken = default)
{
var ids = labelIds.ToList();
return await _store.FindManyAsync(x => ids.Contains(x.LabelId), cancellationToken);
}
/// <inheritdoc />
public async Task ReplaceAsync(IEnumerable<WorkflowDefinitionLabel> removed, IEnumerable<WorkflowDefinitionLabel> added, CancellationToken cancellationToken = default)
{

View file

@ -5,14 +5,17 @@ using Elsa.Common.Models;
using Elsa.Models;
using Elsa.Workflows.Api.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Exceptions;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.List;
[PublicAPI]
internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker linker) : ElsaEndpoint<Request, PagedListResponse<LinkedWorkflowDefinitionSummary>>
internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker linker, IEnumerable<IWorkflowDefinitionFilterProvider>? filterProviders = null) : ElsaEndpoint<Request, PagedListResponse<LinkedWorkflowDefinitionSummary>>
{
public override void Configure()
{
@ -24,12 +27,51 @@ internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker li
{
var pageArgs = PageArgs.FromPage(request.Page, request.PageSize);
var filter = CreateFilter(request);
try
{
foreach (var filterProvider in filterProviders ?? [])
{
if (!filterProvider.CanApply(filter))
{
continue;
}
if (EndpointSecurityOptions.SecurityIsEnabled && !HasRequiredPermissions(filterProvider, filter))
{
AddError("You do not have permission to filter workflow definitions by labels.");
await Send.ErrorsAsync(StatusCodes.Status403Forbidden, cancellationToken);
return default!;
}
await filterProvider.ApplyAsync(filter, cancellationToken);
}
}
catch (WorkflowDefinitionFilterNotSupportedException exception)
{
AddError(exception.Message);
await Send.ErrorsAsync(StatusCodes.Status501NotImplemented, cancellationToken);
return default!;
}
if (filter.LabelIds is { Count: > 0 })
{
AddError("Filtering workflow definitions by labels is not supported by the configured modules.");
await Send.ErrorsAsync(StatusCodes.Status501NotImplemented, cancellationToken);
return default!;
}
var summaries = await FindAsync(request, filter, pageArgs, cancellationToken);
var pagedList = new PagedListResponse<WorkflowDefinitionSummary>(summaries);
var response = linker.MapAsync(pagedList);
return response;
}
private bool HasRequiredPermissions(IWorkflowDefinitionFilterProvider filterProvider, WorkflowDefinitionFilter filter)
{
var evaluator = HttpContext.RequestServices.GetService<IPermissionEvaluator>() ?? PermissionEvaluator.Shared;
return filterProvider.GetRequiredPermissions(filter).All(permission => evaluator.HasPermission(HttpContext.User, permission));
}
private WorkflowDefinitionFilter CreateFilter(Request request)
{
var versionOptions = string.IsNullOrWhiteSpace(request.VersionOptions) ? default(VersionOptions?) : VersionOptions.FromString(request.VersionOptions);
@ -41,7 +83,8 @@ internal class List(IWorkflowDefinitionStore store, IWorkflowDefinitionLinker li
SearchTerm = request.SearchTerm?.Trim(),
MaterializerName = request.MaterializerName,
DefinitionIds = request.DefinitionIds,
Ids = request.Ids
Ids = request.Ids,
LabelIds = request.Labels
};
}

View file

@ -0,0 +1,30 @@
using Elsa.Authorization;
using Elsa.Workflows.Management.Filters;
namespace Elsa.Workflows.Management;
/// <summary>
/// Applies an optional workflow definition filter that is provided by another module. A provider should leave an omitted or empty criterion unchanged, clear each criterion only after successfully applying it, and report unsupported non-empty criteria explicitly.
/// </summary>
public interface IWorkflowDefinitionFilterProvider
{
/// <summary>
/// Gets a value indicating whether this provider can apply criteria to the specified filter.
/// </summary>
/// <param name="filter">The filter to inspect.</param>
bool CanApply(WorkflowDefinitionFilter filter);
/// <summary>
/// Gets the permissions required before this provider can inspect data for the specified filter.
/// </summary>
/// <param name="filter">The filter to inspect.</param>
IEnumerable<Permission> GetRequiredPermissions(WorkflowDefinitionFilter filter) => [];
/// <summary>
/// Applies the provider's criteria to the specified filter.
/// </summary>
/// <remarks>Criteria that remain non-empty after all providers have run are reported as unsupported by the API.</remarks>
/// <param name="filter">The filter to update.</param>
/// <param name="cancellationToken">The cancellation token.</param>
Task ApplyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,6 @@
namespace Elsa.Workflows.Management.Exceptions;
/// <summary>
/// Indicates that a requested workflow definition filter is not supported by the configured modules. The workflow-definition list endpoint reports this condition as HTTP 501.
/// </summary>
public class WorkflowDefinitionFilterNotSupportedException(string message) : NotSupportedException(message);

View file

@ -22,6 +22,11 @@ public class WorkflowDefinitionFilter
/// </summary>
public ICollection<string>? Ids { get; set; }
/// <summary>
/// Filter by label IDs associated with workflow definition versions. An optional filter provider resolves these IDs into version IDs before the store query is executed; any matching label ID qualifies a version, while an omitted or empty collection leaves the results unfiltered. The workflow-definition list endpoint requires label view permission for a non-empty collection.
/// </summary>
public ICollection<string>? LabelIds { get; set; }
/// <summary>
/// Filter by the ID of the workflow definition.
/// </summary>

View file

@ -6,6 +6,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\common\Elsa.Testing.Shared\Elsa.Testing.Shared.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Labels\Elsa.Labels.csproj" />
<ProjectReference Include="..\..\..\src\modules\Elsa.Workflows.Api\Elsa.Workflows.Api.csproj" />
</ItemGroup>

View file

@ -0,0 +1,229 @@
using System.Security.Claims;
using Elsa.Common.Models;
using Elsa.Common.Services;
using Elsa.Labels.Contracts;
using Elsa.Labels.Entities;
using Elsa.Labels.Services;
using Elsa.Models;
using Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.List;
using Elsa.Workflows.Api.Models;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Exceptions;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using Elsa.Workflows.Management.Stores;
using FastEndpoints;
using Microsoft.AspNetCore.Http;
using NSubstitute;
namespace Elsa.Workflows.Api.UnitTests.Endpoints.WorkflowDefinitions;
public class WorkflowDefinitionLabelFilterTests
{
[Fact]
public async Task List_WithLabelId_ReturnsMatchingVersionsAndTotalCount()
{
var response = await ExecuteAsync(["red"], page: 0, pageSize: 1);
Assert.Equal(2, response.TotalCount);
var item = Assert.Single(response.Items);
Assert.Contains(item.Id, new[] { "red-version", "red-second-version" });
var secondPage = await ExecuteAsync(["red"], page: 1, pageSize: 1);
Assert.Equal(2, secondPage.TotalCount);
Assert.Single(secondPage.Items);
Assert.NotEqual(item.Id, secondPage.Items.Single().Id);
Assert.Contains(secondPage.Items.Single().Id, new[] { "red-version", "red-second-version" });
}
[Fact]
public async Task List_WithUnknownLabelId_ReturnsNoResults()
{
var response = await ExecuteAsync(["unknown"]);
Assert.Empty(response.Items);
Assert.Equal(0, response.TotalCount);
}
[Fact]
public async Task List_WithoutLabelIds_ReturnsAllVersions()
{
var response = await ExecuteAsync(null, permissions: ["workflows/definitions:view"]);
Assert.Equal(5, response.TotalCount);
Assert.Equal(5, response.Items.Count);
}
[Fact]
public async Task List_WithWorkflowPermissionWildcard_ReturnsMatchingVersions()
{
var response = await ExecuteAsync(["red"], permissions: ["workflows/definitions/*:view"]);
Assert.Equal(2, response.TotalCount);
Assert.Equal(2, response.Items.Count);
}
[Fact]
public async Task List_WithMultipleLabelIds_UsesAnyMatchingLabel()
{
var response = await ExecuteAsync(["red", "blue"]);
Assert.Equal(3, response.TotalCount);
Assert.Equal(["blue-version", "red-second-version", "red-version"], response.Items.Select(x => x.Id).Order());
}
[Fact]
public async Task List_WithLabelIdsAndVersionIds_IntersectsBothFilters()
{
var response = await ExecuteAsync(["red"], ids: ["red-unlabeled-version", "red-version"]);
Assert.Equal(1, response.TotalCount);
Assert.Equal("red-version", Assert.Single(response.Items).Id);
}
[Fact]
public async Task List_WithLabelFilter_RequiresLabelPermissionBeforeQuerying()
{
var workflowDefinitionStore = Substitute.For<IWorkflowDefinitionStore>();
ConfigureEmptyPage(workflowDefinitionStore);
var labelStore = Substitute.For<IWorkflowDefinitionLabelStore, IWorkflowDefinitionLabelQuery>();
var labelProvider = new WorkflowDefinitionLabelFilterProvider(labelStore);
var endpoint = Factory.Create<List>(
CreateHttpContext("workflows/definitions:view"),
workflowDefinitionStore,
new TestWorkflowDefinitionLinker(),
new IWorkflowDefinitionFilterProvider[] { labelProvider });
await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None);
Assert.Equal(StatusCodes.Status403Forbidden, endpoint.HttpContext.Response.StatusCode);
Assert.Empty(labelStore.ReceivedCalls());
Assert.Empty(workflowDefinitionStore.ReceivedCalls());
}
[Fact]
public async Task List_WithUnrelatedApplicableProvider_LeavesLabelFilterUnsupported()
{
var workflowDefinitionStore = Substitute.For<IWorkflowDefinitionStore>();
ConfigureEmptyPage(workflowDefinitionStore);
var endpoint = Factory.Create<List>(
CreateHttpContext("workflows/definitions:view", "workflows/definitions/labels:view"),
workflowDefinitionStore,
new TestWorkflowDefinitionLinker(),
new IWorkflowDefinitionFilterProvider[] { new UnrelatedFilterProvider() });
await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None);
Assert.Equal(StatusCodes.Status501NotImplemented, endpoint.HttpContext.Response.StatusCode);
Assert.Empty(workflowDefinitionStore.ReceivedCalls());
}
[Fact]
public async Task LabelFilterProvider_WithUnsupportedStoreThrowsExplicitly()
{
var provider = new WorkflowDefinitionLabelFilterProvider(Substitute.For<IWorkflowDefinitionLabelStore>());
var filter = new WorkflowDefinitionFilter { LabelIds = ["red"] };
await Assert.ThrowsAsync<WorkflowDefinitionFilterNotSupportedException>(() => provider.ApplyAsync(filter));
}
[Fact]
public async Task List_WithoutLabelProvider_ReturnsNotImplemented()
{
var store = Substitute.For<IWorkflowDefinitionStore>();
var endpoint = Factory.Create<List>(new DefaultHttpContext(), store, new TestWorkflowDefinitionLinker(), Array.Empty<IWorkflowDefinitionFilterProvider>());
await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None);
Assert.Equal(StatusCodes.Status501NotImplemented, endpoint.HttpContext.Response.StatusCode);
Assert.Empty(store.ReceivedCalls());
}
[Fact]
public async Task List_WithUnsupportedLabelStore_ReturnsNotImplemented()
{
var store = Substitute.For<IWorkflowDefinitionStore>();
var labelStore = Substitute.For<IWorkflowDefinitionLabelStore>();
var provider = new WorkflowDefinitionLabelFilterProvider(labelStore);
var endpoint = Factory.Create<List>(
CreateHttpContext("workflows/definitions:view", "workflows/definitions/labels:view"),
store,
new TestWorkflowDefinitionLinker(),
new IWorkflowDefinitionFilterProvider[] { provider });
await endpoint.ExecuteAsync(new Request { Labels = ["red"] }, CancellationToken.None);
Assert.Equal(StatusCodes.Status501NotImplemented, endpoint.HttpContext.Response.StatusCode);
Assert.Empty(store.ReceivedCalls());
}
private static async Task<PagedListResponse<LinkedWorkflowDefinitionSummary>> ExecuteAsync(string[]? labels, string[]? ids = null, int? page = 0, int? pageSize = null, string[]? permissions = null)
{
var memoryStore = new MemoryStore<WorkflowDefinition>();
var workflowDefinitionStore = new MemoryWorkflowDefinitionStore(memoryStore);
await workflowDefinitionStore.SaveManyAsync(
[
new WorkflowDefinition { Id = "red-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json" },
new WorkflowDefinition { Id = "red-unlabeled-version", DefinitionId = "red", Name = "Red", MaterializerName = "Json" },
new WorkflowDefinition { Id = "red-second-version", DefinitionId = "red-second", Name = "Red second", MaterializerName = "Json" },
new WorkflowDefinition { Id = "blue-version", DefinitionId = "blue", Name = "Blue", MaterializerName = "Json" },
new WorkflowDefinition { Id = "plain-version", DefinitionId = "plain", Name = "Plain", MaterializerName = "Json" }
]);
var labelStore = new InMemoryWorkflowDefinitionLabelStore(new MemoryStore<WorkflowDefinitionLabel>());
await labelStore.SaveManyAsync(
[
new WorkflowDefinitionLabel { Id = "red-association", WorkflowDefinitionId = "red", WorkflowDefinitionVersionId = "red-version", LabelId = "red" },
new WorkflowDefinitionLabel { Id = "red-second-association", WorkflowDefinitionId = "red-second", WorkflowDefinitionVersionId = "red-second-version", LabelId = "red" },
new WorkflowDefinitionLabel { Id = "blue-association", WorkflowDefinitionId = "blue", WorkflowDefinitionVersionId = "blue-version", LabelId = "blue" }
]);
var endpoint = Factory.Create<List>(
CreateHttpContext(permissions ?? ["workflows/definitions:view", "workflows/definitions/labels:view"]),
workflowDefinitionStore,
new TestWorkflowDefinitionLinker(),
new IWorkflowDefinitionFilterProvider[] { new WorkflowDefinitionLabelFilterProvider(labelStore) });
return await endpoint.ExecuteAsync(new Request { Labels = labels, Ids = ids, Page = page, PageSize = pageSize }, CancellationToken.None);
}
private sealed class TestWorkflowDefinitionLinker : IWorkflowDefinitionLinker
{
public Task<LinkedWorkflowDefinitionModel> MapAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public PagedListResponse<LinkedWorkflowDefinitionSummary> MapAsync(PagedListResponse<WorkflowDefinitionSummary> list, CancellationToken cancellationToken = default) => new()
{
Items = list.Items.Select(x => new LinkedWorkflowDefinitionSummary
{
Id = x.Id,
DefinitionId = x.DefinitionId,
Name = x.Name,
Version = x.Version
}).ToList(),
TotalCount = list.TotalCount
};
public Task<List<LinkedWorkflowDefinitionModel>> MapAsync(List<WorkflowDefinition> definitions, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class UnrelatedFilterProvider : IWorkflowDefinitionFilterProvider
{
public bool CanApply(WorkflowDefinitionFilter filter) => true;
public Task ApplyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private static DefaultHttpContext CreateHttpContext(params string[] permissions) => new()
{
User = new ClaimsPrincipal(new ClaimsIdentity(permissions.Select(x => new Claim(PermissionNames.ClaimType, x)), "test"))
};
private static void ConfigureEmptyPage(IWorkflowDefinitionStore store) =>
store.FindSummariesAsync(
Arg.Any<WorkflowDefinitionFilter>(),
Arg.Any<WorkflowDefinitionOrder<string>>(),
Arg.Any<PageArgs>(),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Page.Empty<WorkflowDefinitionSummary>()));
}