[codex] Authorize workflow imports before persistence (#7510)

* Authorize workflow imports before persistence

* Address import authorization review feedback

* Fix import authorization test fixture data
This commit is contained in:
Sipke Schoorstra 2026-05-20 23:29:35 +02:00 committed by GitHub
parent e9d59bc5b1
commit 37c02c74ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 237 additions and 29 deletions

View file

@ -1,6 +1,4 @@
using Elsa.Abstractions;
using Elsa.Workflows.Api.Constants;
using Elsa.Workflows.Api.Requirements;
using Elsa.Workflows.Api.Security;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Models;
@ -15,6 +13,7 @@ namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Import;
[PublicAPI]
internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
{
private readonly IWorkflowDefinitionStore _workflowDefinitionStore;
private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter;
private readonly IWorkflowDefinitionLinker _linker;
private readonly IAuthorizationService _authorizationService;
@ -22,11 +21,13 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
/// <inheritdoc />
public Import(
IWorkflowDefinitionStore workflowDefinitionStore,
IWorkflowDefinitionImporter workflowDefinitionImporter,
IWorkflowDefinitionLinker linker,
IAuthorizationService authorizationService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
{
_workflowDefinitionStore = workflowDefinitionStore;
_workflowDefinitionImporter = workflowDefinitionImporter;
_linker = linker;
_authorizationService = authorizationService;
@ -54,10 +55,7 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
return;
}
var result = await ImportSingleWorkflowDefinitionAsync(model, cancellationToken);
var definition = result.WorkflowDefinition;
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new NotReadOnlyResource(definition), AuthorizationPolicies.NotReadOnlyPolicy);
var authorizationResult = await _authorizationService.AuthorizeWorkflowDefinitionImportAsync(User, _workflowDefinitionStore, model, cancellationToken);
if (!authorizationResult.Succeeded)
{
@ -65,6 +63,8 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
return;
}
var result = await ImportSingleWorkflowDefinitionAsync(model, cancellationToken);
var definition = result.WorkflowDefinition;
var updatedModel = await _linker.MapAsync(definition, cancellationToken);
if (result.Succeeded)
@ -98,5 +98,4 @@ internal class Import : ElsaEndpoint<WorkflowDefinitionModel>
return result;
}
}

View file

@ -0,0 +1,92 @@
using System.Security.Claims;
using Elsa.Common.Models;
using Elsa.Workflows.Api.Constants;
using Elsa.Workflows.Api.Requirements;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using Microsoft.AspNetCore.Authorization;
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions;
internal static class ImportAuthorizationExtensions
{
public static async Task<AuthorizationResult> AuthorizeWorkflowDefinitionImportAsync(
this IAuthorizationService authorizationService,
ClaimsPrincipal user,
IWorkflowDefinitionStore workflowDefinitionStore,
WorkflowDefinitionModel model,
CancellationToken cancellationToken)
{
var definition = await FindExistingDefinitionAsync(workflowDefinitionStore, model.DefinitionId, cancellationToken);
return await authorizationService.AuthorizeAsync(user, new NotReadOnlyResource(definition), AuthorizationPolicies.NotReadOnlyPolicy);
}
public static async Task<AuthorizationResult> AuthorizeWorkflowDefinitionImportsAsync(
this IAuthorizationService authorizationService,
ClaimsPrincipal user,
IWorkflowDefinitionStore workflowDefinitionStore,
IEnumerable<WorkflowDefinitionModel> models,
CancellationToken cancellationToken)
{
var modelList = models.ToList();
if (modelList.Count == 0)
return await authorizationService.AuthorizeAsync(user, new NotReadOnlyResource(), AuthorizationPolicies.NotReadOnlyPolicy);
var definitions = await FindExistingDefinitionsAsync(workflowDefinitionStore, modelList, cancellationToken);
foreach (var model in modelList)
{
definitions.TryGetValue(model.DefinitionId ?? string.Empty, out var definition);
var authorizationResult = await authorizationService.AuthorizeAsync(user, new NotReadOnlyResource(definition), AuthorizationPolicies.NotReadOnlyPolicy);
if (!authorizationResult.Succeeded)
return authorizationResult;
}
return AuthorizationResult.Success();
}
private static async Task<WorkflowDefinition?> FindExistingDefinitionAsync(
IWorkflowDefinitionStore workflowDefinitionStore,
string? definitionId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(definitionId))
return null;
return await workflowDefinitionStore.FindAsync(new WorkflowDefinitionFilter
{
DefinitionId = definitionId,
VersionOptions = VersionOptions.Latest
}, cancellationToken);
}
private static async Task<IDictionary<string, WorkflowDefinition>> FindExistingDefinitionsAsync(
IWorkflowDefinitionStore workflowDefinitionStore,
IEnumerable<WorkflowDefinitionModel> models,
CancellationToken cancellationToken)
{
var definitionIds = models
.Select(x => x.DefinitionId)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (definitionIds.Count == 0)
return new Dictionary<string, WorkflowDefinition>();
var definitions = await workflowDefinitionStore.FindManyAsync(new WorkflowDefinitionFilter
{
DefinitionIds = definitionIds,
VersionOptions = VersionOptions.Latest
}, cancellationToken);
return definitions
.GroupBy(x => x.DefinitionId, StringComparer.Ordinal)
.ToDictionary(x => x.Key, x => x.First(), StringComparer.Ordinal);
}
}

View file

@ -1,9 +1,6 @@
using Elsa.Abstractions;
using Elsa.Workflows.Api.Constants;
using Elsa.Workflows.Api.Requirements;
using Elsa.Workflows.Api.Security;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Mappers;
using Elsa.Workflows.Management.Models;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authorization;
@ -17,25 +14,22 @@ namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.ImportFiles;
[PublicAPI]
internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
{
private readonly IWorkflowDefinitionService _workflowDefinitionService;
private readonly IWorkflowDefinitionStore _workflowDefinitionStore;
private readonly IWorkflowDefinitionImporter _workflowDefinitionImporter;
private readonly WorkflowDefinitionMapper _workflowDefinitionMapper;
private readonly IApiSerializer _apiSerializer;
private readonly IAuthorizationService _authorizationService;
private readonly PythonWorkflowDefinitionAuthorizationService _pythonAuthorizationService;
/// <inheritdoc />
public ImportFiles(
IWorkflowDefinitionService workflowDefinitionService,
IWorkflowDefinitionStore workflowDefinitionStore,
IWorkflowDefinitionImporter workflowDefinitionImporter,
WorkflowDefinitionMapper workflowDefinitionMapper,
IApiSerializer apiSerializer,
IAuthorizationService authorizationService,
PythonWorkflowDefinitionAuthorizationService pythonAuthorizationService)
{
_workflowDefinitionService = workflowDefinitionService;
_workflowDefinitionStore = workflowDefinitionStore;
_workflowDefinitionImporter = workflowDefinitionImporter;
_workflowDefinitionMapper = workflowDefinitionMapper;
_apiSerializer = apiSerializer;
_authorizationService = authorizationService;
_pythonAuthorizationService = pythonAuthorizationService;
@ -52,17 +46,21 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
/// <inheritdoc />
public override async Task HandleAsync(WorkflowDefinitionModel model, CancellationToken cancellationToken)
{
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new NotReadOnlyResource(), AuthorizationPolicies.NotReadOnlyPolicy);
if (!authorizationResult.Succeeded)
{
await Send.ForbiddenAsync(cancellationToken);
return;
}
if (Files.Any())
{
var count = await ImportFilesAsync(Files, cancellationToken);
var models = await ReadWorkflowDefinitionModelsAsync(Files, cancellationToken);
if (!await AuthorizePythonUsageAsync(models, cancellationToken))
return;
var authorizationResult = await _authorizationService.AuthorizeWorkflowDefinitionImportsAsync(User, _workflowDefinitionStore, models, cancellationToken);
if (!authorizationResult.Succeeded)
{
await Send.ForbiddenAsync(cancellationToken);
return;
}
var count = await ImportWorkflowDefinitionsAsync(models, cancellationToken);
if (!ValidationFailed && !HttpContext.Response.HasStarted)
await Send.OkAsync(new { Count = count }, cancellationToken);
@ -72,28 +70,39 @@ internal class ImportFiles : ElsaEndpoint<WorkflowDefinitionModel>
await Send.ErrorsAsync(400, cancellationToken);
}
private async Task<int> ImportFilesAsync(IFormFileCollection files, CancellationToken cancellationToken)
private async Task<ICollection<WorkflowDefinitionModel>> ReadWorkflowDefinitionModelsAsync(IFormFileCollection files, CancellationToken cancellationToken)
{
var models = await WorkflowDefinitionImportFileReader.ReadAsync(files, _apiSerializer, () => HttpContext.Response.HasStarted, cancellationToken);
return models.ToList();
}
private async Task<bool> AuthorizePythonUsageAsync(IEnumerable<WorkflowDefinitionModel> models, CancellationToken cancellationToken)
{
foreach (var model in models)
{
var pythonAuthorizationResult = await _pythonAuthorizationService.AuthorizeAsync(model, User, cancellationToken);
if (pythonAuthorizationResult != PythonWorkflowDefinitionAuthorizationResult.Allowed)
{
await PythonWorkflowDefinitionAuthorizationFailure.SendAsync(pythonAuthorizationResult, Send.ForbiddenAsync, message => AddError(message), Send.ErrorsAsync, cancellationToken);
return 0;
return false;
}
}
return true;
}
private async Task<int> ImportWorkflowDefinitionsAsync(IEnumerable<WorkflowDefinitionModel> models, CancellationToken cancellationToken)
{
var count = 0;
foreach (var model in models)
{
if (HttpContext.Response.HasStarted)
return count;
await ImportSingleWorkflowDefinitionAsync(model, cancellationToken);
count++;
var result = await ImportSingleWorkflowDefinitionAsync(model, cancellationToken);
if (result.Succeeded)
count++;
}
return count;

View file

@ -0,0 +1,108 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts;
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
using Elsa.Workflows;
using Elsa.Workflows.Activities;
using Elsa.Workflows.ComponentTests.Abstractions;
using Elsa.Workflows.ComponentTests.Fixtures;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Materializers;
using Microsoft.Extensions.DependencyInjection;
using Refit;
using WorkflowDefinitionEntity = Elsa.Workflows.Management.Entities.WorkflowDefinition;
namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.WorkflowDefinitions.Import;
public class ImportAuthorizationTests : AppComponentTest
{
private readonly IWorkflowDefinitionStore _store;
private readonly IActivitySerializer _activitySerializer;
private readonly IWorkflowDefinitionsApi _client;
public ImportAuthorizationTests(App app) : base(app)
{
_store = Scope.ServiceProvider.GetRequiredService<IWorkflowDefinitionStore>();
_activitySerializer = Scope.ServiceProvider.GetRequiredService<IActivitySerializer>();
_client = WorkflowServer.CreateApiClient<IWorkflowDefinitionsApi>();
}
[Fact]
public async Task ImportExistingReadOnlyDefinition_ShouldReturnForbiddenAndLeaveStorageUnchanged()
{
var definitionId = $"readonly-import-{Guid.NewGuid():N}";
await SaveDefinitionAsync(definitionId, "Original", isReadonly: true);
var exception = await Assert.ThrowsAsync<ApiException>(() => _client.ImportAsync(CreateImportModel(definitionId, "Updated")));
Assert.Equal(HttpStatusCode.Forbidden, exception.StatusCode);
await AssertDefinitionUnchangedAsync(definitionId, "Original", isReadonly: true);
}
[Fact]
public async Task ImportFilesWithReadOnlyTarget_ShouldReturnForbiddenAndLeaveStorageUnchanged()
{
var writableDefinitionId = $"writable-import-files-{Guid.NewGuid():N}";
var readOnlyDefinitionId = $"readonly-import-files-{Guid.NewGuid():N}";
await SaveDefinitionAsync(writableDefinitionId, "Writable Original");
await SaveDefinitionAsync(readOnlyDefinitionId, "ReadOnly Original", isReadonly: true);
await using var writableStream = CreateImportStream(writableDefinitionId, "Writable Updated");
await using var readOnlyStream = CreateImportStream(readOnlyDefinitionId, "ReadOnly Updated");
var files = new List<StreamPart>
{
new(writableStream, "writable.json", "application/json"),
new(readOnlyStream, "readonly.json", "application/json")
};
var exception = await Assert.ThrowsAsync<ApiException>(() => _client.ImportFilesAsync(files));
Assert.Equal(HttpStatusCode.Forbidden, exception.StatusCode);
await AssertDefinitionUnchangedAsync(writableDefinitionId, "Writable Original");
await AssertDefinitionUnchangedAsync(readOnlyDefinitionId, "ReadOnly Original", isReadonly: true);
}
private async Task SaveDefinitionAsync(string definitionId, string name, bool isReadonly = false)
{
await _store.SaveAsync(new WorkflowDefinitionEntity
{
Id = Guid.NewGuid().ToString("N"),
DefinitionId = definitionId,
Name = name,
CreatedAt = DateTimeOffset.UtcNow,
IsLatest = true,
IsReadonly = isReadonly,
MaterializerName = JsonWorkflowMaterializer.MaterializerName,
StringData = _activitySerializer.Serialize(new Sequence())
});
}
private async Task AssertDefinitionUnchangedAsync(string definitionId, string expectedName, bool isReadonly = false)
{
var definitions = (await _store.FindManyAsync(new WorkflowDefinitionFilter
{
DefinitionId = definitionId
})).ToList();
var definition = Assert.Single(definitions);
Assert.Equal(expectedName, definition.Name);
Assert.Equal(isReadonly, definition.IsReadonly);
}
private static WorkflowDefinitionModel CreateImportModel(string definitionId, string name)
{
return new()
{
DefinitionId = definitionId,
Name = name
};
}
private static MemoryStream CreateImportStream(string definitionId, string name)
{
var json = JsonSerializer.Serialize(CreateImportModel(definitionId, name), new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new(Encoding.UTF8.GetBytes(json));
}
}