Skip to content

Filter operations based on GenerateControllerEndpoints usage #1561

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/usage/writing/bulk-batch-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph,
ILoggerFactory loggerFactory, IOperationsProcessor processor,
IJsonApiRequest request, ITargetedFields targetedFields)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
IJsonApiRequest request, ITargetedFields targetedFields,
IAtomicOperationFilter operationFilter)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields,
operationFilter)
{
}
}
```

> [!IMPORTANT]
> Since v5.6.0, the set of exposed operations is based on
> [`GenerateControllerEndpoints` usage](~/usage/extensibility/controllers.md#resource-access-control).
> Earlier versions always exposed all operations for all resource types.
> If you're using [explicit controllers](~/usage/extensibility/controllers.md#explicit-controllers),
> register and implement your own
> [`IAtomicOperationFilter`](~/api/JsonApiDotNetCore.AtomicOperations.IAtomicOperationFilter.yml)
> to indicate which operations to expose.

You'll need to send the next Content-Type in a POST request for operations:

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ namespace DapperExample.Controllers;

public sealed class OperationsController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
request, targetedFields, operationFilter);
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ namespace JsonApiDotNetCoreExample.Controllers;

public sealed class OperationsController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
request, targetedFields, operationFilter);
32 changes: 32 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Reflection;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.AtomicOperations;

/// <inheritdoc />
internal sealed class DefaultOperationFilter : IAtomicOperationFilter
{
/// <inheritdoc />
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
{
var resourceAttribute = resourceType.ClrType.GetCustomAttribute<ResourceAttribute>();
return resourceAttribute != null && Contains(resourceAttribute.GenerateControllerEndpoints, writeOperation);
}

private static bool Contains(JsonApiEndpoints endpoints, WriteOperationKind writeOperation)
{
return writeOperation switch
{
WriteOperationKind.CreateResource => endpoints.HasFlag(JsonApiEndpoints.Post),
WriteOperationKind.UpdateResource => endpoints.HasFlag(JsonApiEndpoints.Patch),
WriteOperationKind.DeleteResource => endpoints.HasFlag(JsonApiEndpoints.Delete),
WriteOperationKind.SetRelationship => endpoints.HasFlag(JsonApiEndpoints.PatchRelationship),
WriteOperationKind.AddToRelationship => endpoints.HasFlag(JsonApiEndpoints.PostRelationship),
WriteOperationKind.RemoveFromRelationship => endpoints.HasFlag(JsonApiEndpoints.DeleteRelationship),
_ => false
};
}
}
42 changes: 42 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.AtomicOperations;

/// <summary>
/// Determines whether an operation in an atomic:operations request can be used.
/// </summary>
/// <remarks>
/// The default implementation relies on the usage of <see cref="ResourceAttribute.GenerateControllerEndpoints" />. If you're using explicit
/// (non-generated) controllers, register your own implementation to indicate which operations are accessible.
/// </remarks>
[PublicAPI]
public interface IAtomicOperationFilter
{
/// <summary>
/// An <see cref="IAtomicOperationFilter" /> that always returns <c>true</c>. Provided for convenience, to revert to the original behavior from before
/// filtering was introduced.
/// </summary>
public static IAtomicOperationFilter AlwaysEnabled { get; } = new AlwaysEnabledOperationFilter();

/// <summary>
/// Determines whether the specified operation can be used in an atomic:operations request.
/// </summary>
/// <param name="resourceType">
/// The targeted primary resource type of the operation.
/// </param>
/// <param name="writeOperation">
/// The operation kind.
/// </param>
bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation);

private sealed class AlwaysEnabledOperationFilter : IAtomicOperationFilter
{
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
{
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -300,5 +300,6 @@ private void AddOperationsLayer()
_services.TryAddScoped<IOperationsProcessor, OperationsProcessor>();
_services.TryAddScoped<IOperationProcessorAccessor, OperationProcessorAccessor>();
_services.TryAddScoped<ILocalIdTracker, LocalIdTracker>();
_services.TryAddSingleton<IAtomicOperationFilter, DefaultOperationFilter>();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;
Expand All @@ -22,23 +24,26 @@ public abstract class BaseJsonApiOperationsController : CoreJsonApiController
private readonly IOperationsProcessor _processor;
private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
private readonly IAtomicOperationFilter _operationFilter;
private readonly TraceLogWriter<BaseJsonApiOperationsController> _traceWriter;

protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory,
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields)
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields, IAtomicOperationFilter operationFilter)
{
ArgumentGuard.NotNull(options);
ArgumentGuard.NotNull(resourceGraph);
ArgumentGuard.NotNull(loggerFactory);
ArgumentGuard.NotNull(processor);
ArgumentGuard.NotNull(request);
ArgumentGuard.NotNull(targetedFields);
ArgumentGuard.NotNull(operationFilter);

_options = options;
_resourceGraph = resourceGraph;
_processor = processor;
_request = request;
_targetedFields = targetedFields;
_operationFilter = operationFilter;
_traceWriter = new TraceLogWriter<BaseJsonApiOperationsController>(loggerFactory);
}

Expand Down Expand Up @@ -111,6 +116,8 @@ public virtual async Task<IActionResult> PostOperationsAsync([FromBody] IList<Op

ArgumentGuard.NotNull(operations);

ValidateEnabledOperations(operations);

if (_options.ValidateModelState)
{
ValidateModelState(operations);
Expand All @@ -120,6 +127,68 @@ public virtual async Task<IActionResult> PostOperationsAsync([FromBody] IList<Op
return results.Any(result => result != null) ? Ok(results) : NoContent();
}

protected virtual void ValidateEnabledOperations(IList<OperationContainer> operations)
{
List<ErrorObject> errors = [];

for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++)
{
IJsonApiRequest operationRequest = operations[operationIndex].Request;
WriteOperationKind operationKind = operationRequest.WriteOperation!.Value;

if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, operationKind))
{
string operationCode = GetOperationCodeText(operationKind);

errors.Add(new ErrorObject(HttpStatusCode.UnprocessableEntity)
{
Title = "The requested operation is not accessible.",
Detail = $"The '{operationCode}' relationship operation is not accessible for relationship '{operationRequest.Relationship}' " +
$"on resource type '{operationRequest.Relationship.LeftType}'.",
Source = new ErrorSource
{
Pointer = $"/atomic:operations[{operationIndex}]"
}
});
}
else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, operationKind))
{
string operationCode = GetOperationCodeText(operationKind);

errors.Add(new ErrorObject(HttpStatusCode.UnprocessableEntity)
{
Title = "The requested operation is not accessible.",
Detail = $"The '{operationCode}' resource operation is not accessible for resource type '{operationRequest.PrimaryResourceType}'.",
Source = new ErrorSource
{
Pointer = $"/atomic:operations[{operationIndex}]"
}
});
}
}

if (errors.Count > 0)
{
throw new JsonApiException(errors);
}
}

private static string GetOperationCodeText(WriteOperationKind operationKind)
{
AtomicOperationCode operationCode = operationKind switch
{
WriteOperationKind.CreateResource => AtomicOperationCode.Add,
WriteOperationKind.UpdateResource => AtomicOperationCode.Update,
WriteOperationKind.DeleteResource => AtomicOperationCode.Remove,
WriteOperationKind.AddToRelationship => AtomicOperationCode.Add,
WriteOperationKind.SetRelationship => AtomicOperationCode.Update,
WriteOperationKind.RemoveFromRelationship => AtomicOperationCode.Remove,
_ => throw new NotSupportedException($"Unknown operation kind '{operationKind}'.")
};

return operationCode.ToString().ToLowerInvariant();
}

protected virtual void ValidateModelState(IList<OperationContainer> operations)
{
// We must validate the resource inside each operation manually, because they are typed as IIdentifiable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ namespace JsonApiDotNetCore.Controllers;
/// </summary>
public abstract class JsonApiOperationsController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields)
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
request, targetedFields, operationFilter)
{
/// <inheritdoc />
[HttpPost]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@

namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers;

public sealed class AtomicConstrainedOperationsControllerTests
public sealed class AtomicCustomConstrainedOperationsControllerTests
: IClassFixture<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>>
{
private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext;
private readonly OperationsFakers _fakers = new();

public AtomicConstrainedOperationsControllerTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext)
public AtomicCustomConstrainedOperationsControllerTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext)
{
_testContext = testContext;

Expand Down Expand Up @@ -102,14 +102,14 @@ public async Task Cannot_create_resource_for_mismatching_resource_type()

ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint.");
error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'.");
error.Title.Should().Be("The requested operation is not accessible.");
error.Detail.Should().Be("The 'add' resource operation is not accessible for resource type 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]");
}

[Fact]
public async Task Cannot_update_resources_for_matching_resource_type()
public async Task Cannot_update_resource_for_matching_resource_type()
{
// Arrange
MusicTrack existingTrack = _fakers.MusicTrack.Generate();
Expand Down Expand Up @@ -151,8 +151,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>

ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint.");
error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'.");
error.Title.Should().Be("The requested operation is not accessible.");
error.Detail.Should().Be("The 'update' resource operation is not accessible for resource type 'musicTracks'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]");
}
Expand Down Expand Up @@ -207,8 +207,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>

ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint.");
error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'.");
error.Title.Should().Be("The requested operation is not accessible.");
error.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'performers' on resource type 'musicTracks'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]");
}
Expand Down
Loading
Loading