[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:
parent
e9d59bc5b1
commit
37c02c74ff
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue