From c4642ee6ef88dd6506d172ed5682f65b1ec1c35f Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:38:15 +0000 Subject: [PATCH] Apply authorization to batch resolver fields --- .../AuthorizationTypeInterceptor.cs | 73 +++ .../Authorization/AuthorizeBatchMiddleware.cs | 84 ++++ .../src/Authorization/AuthorizeMiddleware.cs | 11 +- .../BatchResolverAuthorizationTests.cs | 415 ++++++++++++++++++ 4 files changed, 578 insertions(+), 5 deletions(-) create mode 100644 src/HotChocolate/Core/src/Authorization/AuthorizeBatchMiddleware.cs create mode 100644 src/HotChocolate/Core/test/Authorization.Tests/BatchResolverAuthorizationTests.cs diff --git a/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs b/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs index 785b0649b28..87c8c50d3f1 100644 --- a/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs @@ -131,6 +131,47 @@ public override void OnBeforeCompleteMetadata( } } + public override void OnBeforeMakeExecutable( + ITypeCompletionContext completionContext, + TypeSystemConfiguration configuration) + { + if (completionContext.Type is not ObjectType objectType + || configuration is not ObjectTypeConfiguration typeDef) + { + return; + } + + foreach (var fieldDef in typeDef.Fields) + { + if (fieldDef.BatchResolver is null + || !objectType.Fields.TryGetField(fieldDef.Name, out var field)) + { + continue; + } + + var directives = field.Directives; + var length = directives.Count; + ref var start = ref directives.GetReference(); + + for (var i = length - 1; i >= 0; i--) + { + var directive = Unsafe.Add(ref start, i); + + if (directive.Type.Name.EqualsOrdinal(Authorize)) + { + var authDir = directive.ToValue(); + + // field directives go in front of the type level authorization, + // in the same order as in the field pipeline. + fieldDef.BatchMiddlewareConfigurations.Insert( + 0, + CreateBatchAuthMiddleware( + authDir)); + } + } + } + } + public override void OnAfterMakeExecutable() { foreach (var type in _objectTypes) @@ -411,6 +452,7 @@ private void ApplyAuthMiddleware( bool isNodeField) { var insertPos = 0; + var batchInsertPos = 0; // we try to locate the last auth middleware and insert after it. if (fieldDef.MiddlewareConfigurations.Count > 0) @@ -428,6 +470,22 @@ private void ApplyAuthMiddleware( } } + // we do the same for the batch pipeline. + if (fieldDef.BatchResolver is not null) + { + for (var i = 0; i < fieldDef.BatchMiddlewareConfigurations.Count; i++) + { + if (fieldDef.BatchMiddlewareConfigurations[i].Key == WellKnownMiddleware.Authorization) + { + batchInsertPos = i + 1; + } + else + { + break; + } + } + } + // next we get the collection of directives for the object type that needs to be secured. var directives = GetOrCreateDirectives(authTypeReg); var length = directives.Count; @@ -461,6 +519,14 @@ private void ApplyAuthMiddleware( insertPos++, CreateAuthMiddleware( authDir)); + + if (fieldDef.BatchResolver is not null) + { + fieldDef.BatchMiddlewareConfigurations.Insert( + batchInsertPos++, + CreateBatchAuthMiddleware( + authDir)); + } } } } @@ -482,6 +548,13 @@ private static FieldMiddlewareConfiguration CreateAuthMiddleware( isRepeatable: true, key: WellKnownMiddleware.Authorization); + private static BatchFieldMiddlewareConfiguration CreateBatchAuthMiddleware( + AuthorizeDirective directive) + => new BatchFieldMiddlewareConfiguration( + next => new AuthorizeBatchMiddleware(next, directive).InvokeAsync, + isRepeatable: true, + key: WellKnownMiddleware.Authorization); + private DirectiveCollection GetOrCreateDirectives(RegisteredType registration) { var type = (ObjectType)registration.Type; diff --git a/src/HotChocolate/Core/src/Authorization/AuthorizeBatchMiddleware.cs b/src/HotChocolate/Core/src/Authorization/AuthorizeBatchMiddleware.cs new file mode 100644 index 00000000000..1cc4d48a11b --- /dev/null +++ b/src/HotChocolate/Core/src/Authorization/AuthorizeBatchMiddleware.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; +using HotChocolate.Resolvers; + +namespace HotChocolate.Authorization; + +internal sealed class AuthorizeBatchMiddleware( + BatchFieldDelegate next, + AuthorizeDirective directive) +{ + private readonly BatchFieldDelegate _next = next ?? + throw new ArgumentNullException(nameof(next)); + private readonly AuthorizeDirective _directive = directive ?? + throw new ArgumentNullException(nameof(directive)); + + public async ValueTask InvokeAsync(ImmutableArray contexts) + { + switch (_directive.Apply) + { + case ApplyPolicy.AfterResolver: + { + await _next(contexts).ConfigureAwait(false); + + foreach (var context in contexts) + { + if (context.Result is null) + { + continue; + } + + var handler = context.GetAuthorizationHandler(); + var state = await handler.AuthorizeAsync(context, _directive).ConfigureAwait(false); + + if (state != AuthorizeResult.Allowed && !AuthorizeMiddleware.IsErrorResult(context)) + { + AuthorizeMiddleware.SetError(context, _directive, state); + } + } + break; + } + + case ApplyPolicy.BeforeResolver: + { + // the authorization result can depend on the parent object, so each context + // is authorized on its own and only the allowed ones are passed on to the resolver. + ImmutableArray.Builder? allowed = null; + + for (var i = 0; i < contexts.Length; i++) + { + var context = contexts[i]; + var handler = context.GetAuthorizationHandler(); + var state = await handler.AuthorizeAsync(context, _directive).ConfigureAwait(false); + + if (state == AuthorizeResult.Allowed) + { + allowed?.Add(context); + continue; + } + + if (allowed is null) + { + allowed = ImmutableArray.CreateBuilder(contexts.Length - 1); + allowed.AddRange(contexts, i); + } + + AuthorizeMiddleware.SetError(context, _directive, state); + } + + if (allowed is null) + { + await _next(contexts).ConfigureAwait(false); + } + else if (allowed.Count > 0) + { + await _next(allowed.ToImmutable()).ConfigureAwait(false); + } + break; + } + + default: + await _next(contexts).ConfigureAwait(false); + break; + } + } +} diff --git a/src/HotChocolate/Core/src/Authorization/AuthorizeMiddleware.cs b/src/HotChocolate/Core/src/Authorization/AuthorizeMiddleware.cs index 229c95bb1fc..65413bda952 100644 --- a/src/HotChocolate/Core/src/Authorization/AuthorizeMiddleware.cs +++ b/src/HotChocolate/Core/src/Authorization/AuthorizeMiddleware.cs @@ -29,7 +29,7 @@ public async Task InvokeAsync(IMiddlewareContext context) if (state != AuthorizeResult.Allowed && !IsErrorResult(context)) { - SetError(context, state); + SetError(context, _directive, state); } } break; @@ -45,7 +45,7 @@ public async Task InvokeAsync(IMiddlewareContext context) } else { - SetError(context, state); + SetError(context, _directive, state); } break; } @@ -56,11 +56,12 @@ public async Task InvokeAsync(IMiddlewareContext context) } } - private static bool IsErrorResult(IMiddlewareContext context) + internal static bool IsErrorResult(IMiddlewareContext context) => context.Result is IError or IEnumerable; - private void SetError( + internal static void SetError( IMiddlewareContext context, + AuthorizeDirective directive, AuthorizeResult state) => context.Result = state switch { @@ -74,7 +75,7 @@ private void SetError( => ErrorBuilder.New() .SetMessage( AuthorizeMiddleware_PolicyNotFound, - _directive.Policy) + directive.Policy) .SetCode(ErrorCodes.Authentication.PolicyNotFound) .SetPath(context.Path) .Build(), diff --git a/src/HotChocolate/Core/test/Authorization.Tests/BatchResolverAuthorizationTests.cs b/src/HotChocolate/Core/test/Authorization.Tests/BatchResolverAuthorizationTests.cs new file mode 100644 index 00000000000..889836832a3 --- /dev/null +++ b/src/HotChocolate/Core/test/Authorization.Tests/BatchResolverAuthorizationTests.cs @@ -0,0 +1,415 @@ +using System.Text.Json; +using HotChocolate.Execution; +using HotChocolate.Resolvers; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Authorization; + +public class BatchResolverAuthorizationTests +{ + [Theory] + [InlineData("{ users { value } }", "value")] + [InlineData("{ users { valueAfterResolver } }", "valueAfterResolver")] + [InlineData("{ users { item { value } } }", "item")] + [InlineData("{ users { itemAfterResolver { value } } }", "itemAfterResolver")] + [InlineData("{ users { document { ... on Item { value } } } }", "document")] + public async Task BatchResolver_Should_ReturnAuthorizationErrors_When_NotAllowed( + string query, + string field) + { + // arrange + var handler = new AuthHandler((_, _) => AuthorizeResult.NotAllowed); + + // act + var result = await ExecuteAsync(handler, query); + + // assert + AssertNotAuthorized(result, field); + } + + [Theory] + [InlineData("{ users { value } }", "value")] + [InlineData("{ users { valueAfterResolver } }", "valueAfterResolver")] + [InlineData("{ users { item { value } } }", "item")] + [InlineData("{ users { itemAfterResolver { value } } }", "itemAfterResolver")] + [InlineData("{ users { document { ... on Item { value } } } }", "document")] + public async Task BatchResolver_Should_ResolveValues_When_Allowed( + string query, + string field) + { + // arrange + var handler = new AuthHandler((_, _) => AuthorizeResult.Allowed); + + // act + var result = await ExecuteAsync(handler, query); + + // assert + Assert.Empty(result.ExpectOperationResult().Errors); + Assert.All( + GetUsers(result), + user => Assert.NotEqual(JsonValueKind.Null, GetValueKind(user, field))); + } + + [Fact] + public async Task BatchResolver_Should_OnlyReceiveAllowedParents_When_AuthorizedBeforeResolver() + { + // arrange + var handler = new AuthHandler( + (context, _) => context.Parent().Name is "a" + ? AuthorizeResult.Allowed + : AuthorizeResult.NotAllowed); + + // act + var result = await ExecuteAsync(handler, "{ users { name parentNames } }"); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The current user is not authorized to access this resource.", + "path": [ + "users", + 1, + "parentNames" + ], + "extensions": { + "code": "AUTH_NOT_AUTHORIZED" + } + } + ], + "data": { + "users": [ + { + "name": "a", + "parentNames": "a" + }, + { + "name": "b", + "parentNames": null + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Should_OnlyReceiveAllowedParents_When_FirstParentIsNotAllowed() + { + // arrange + var handler = new AuthHandler( + (context, _) => context.Parent().Name is "b" + ? AuthorizeResult.Allowed + : AuthorizeResult.NotAllowed); + + // act + var result = await ExecuteAsync(handler, "{ users { name parentNames } }"); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The current user is not authorized to access this resource.", + "path": [ + "users", + 0, + "parentNames" + ], + "extensions": { + "code": "AUTH_NOT_AUTHORIZED" + } + } + ], + "data": { + "users": [ + { + "name": "a", + "parentNames": null + }, + { + "name": "b", + "parentNames": "b" + } + ] + } + } + """); + } + + [Fact] + public async Task BatchResolver_Should_ApplyPoliciesInFieldPipelineOrder_When_FieldAndTypeAreAuthorized() + { + // arrange + var policies = new List(); + var handler = new AuthHandler( + (context, directive) => + { + if (context.Parent().Name is "a") + { + policies.Add(directive.Policy); + } + + return AuthorizeResult.Allowed; + }); + + // act + await ExecuteAsync(handler, "{ users { regular { value } } }"); + var expected = policies.ToArray(); + policies.Clear(); + await ExecuteAsync(handler, "{ users { batched { value } } }"); + + // assert + Assert.Equal(["FIELD_1", "FIELD_2", "TYPE_2", "TYPE_1"], expected); + Assert.Equal(expected, policies); + } + + [Fact] + public async Task BatchResolver_Should_ReturnAuthorizationError_When_RootTypeIsNotAllowed() + { + // arrange + var handler = new AuthHandler((_, _) => AuthorizeResult.NotAllowed); + + // act + var result = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType( + d => + { + d.Name("Query"); + d.Authorize(); + d.Field("regular") + .Type() + .Resolve("value"); + d.Field("batched") + .Type() + .ResolveBatch(ResolveValues); + }) + .AddAuthorizationHandler(_ => handler) + .ExecuteRequestAsync("{ regular batched }", cancellationToken: TestContext.Current.CancellationToken); + + // assert + using var json = JsonDocument.Parse(result.ToJson()); + var data = json.RootElement.GetProperty("data"); + var operationResult = result.ExpectOperationResult(); + Assert.Equal( + ["batched", "regular"], + operationResult.Errors.Select(e => e.Path!.ToString().TrimStart('/')).Order()); + Assert.All( + operationResult.Errors, + e => Assert.Equal(ErrorCodes.Authentication.NotAuthorized, e.Code)); + Assert.Equal( + [JsonValueKind.Null, JsonValueKind.Null], + [data.GetProperty("regular").ValueKind, data.GetProperty("batched").ValueKind]); + } + + [Fact] + public async Task BatchResolver_Should_ReturnAuthorizationErrors_When_SchemaFirstFieldIsNotAllowed() + { + // arrange + var handler = new AuthHandler((_, _) => AuthorizeResult.NotAllowed); + + // act + var result = + await new ServiceCollection() + .AddGraphQLServer() + .AddDocumentFromString( + """ + type Query { + users: [User!]! + } + + type User { + name: String! + value: String @authorize(policy: "READ_VALUE") + } + """) + .BindRuntimeType() + .BindRuntimeType() + .AddTypeExtension( + new ObjectTypeExtension( + d => d.Name("User").Field("value").ResolveBatch(ResolveValues))) + .AddAuthorizationHandler(_ => handler) + .ExecuteRequestAsync( + "{ users { name value } }", + cancellationToken: TestContext.Current.CancellationToken); + + // assert + AssertNotAuthorized(result, "value"); + } + + [Fact] + public async Task BatchResolver_Should_ReturnAuthorizationErrors_When_InheritedFromInterfaceAndNotAllowed() + { + // arrange + var handler = new AuthHandler((_, _) => AuthorizeResult.NotAllowed); + + // act + var result = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType( + d => + { + d.Name("Query"); + d.Field("users") + .Type>>() + .Resolve(new List { new("a"), new("b") }); + }) + .AddInterfaceType( + d => d.Field("value").ResolveBatchWith(t => t.Value(null!))) + .AddObjectType( + d => + { + d.Implements>(); + d.Field("value").Type().Authorize(); + }) + .AddAuthorizationHandler(_ => handler) + .ExecuteRequestAsync( + "{ users { name value } }", + cancellationToken: TestContext.Current.CancellationToken); + + // assert + AssertNotAuthorized(result, "value"); + } + + private static async Task ExecuteAsync( + IAuthorizationHandler handler, + string query) + => await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .AddTypeExtension() + .AddType() + .AddAuthorizationHandler(_ => handler) + .ExecuteRequestAsync(query, cancellationToken: TestContext.Current.CancellationToken); + + private static ValueTask> ResolveValues( + IReadOnlyList contexts) + { + var results = new ResolverResult[contexts.Count]; + Array.Fill(results, ResolverResult.Ok("value")); + return new ValueTask>(results); + } + + private static void AssertNotAuthorized(IExecutionResult result, string field) + { + Assert.Collection( + result.ExpectOperationResult().Errors, + e => Assert.Equal(ErrorCodes.Authentication.NotAuthorized, e.Code), + e => Assert.Equal(ErrorCodes.Authentication.NotAuthorized, e.Code)); + Assert.All( + GetUsers(result), + user => Assert.Equal(JsonValueKind.Null, GetValueKind(user, field))); + } + + private static List GetUsers(IExecutionResult result) + { + using var document = JsonDocument.Parse(result.ToJson()); + return document.RootElement + .GetProperty("data") + .GetProperty("users") + .EnumerateArray() + .Select(t => t.Clone()) + .ToList(); + } + + private static JsonValueKind GetValueKind(JsonElement user, string field) + => user.GetProperty(field).ValueKind; + + public sealed class Query + { + public List Users => [new User("a"), new User("b")]; + } + + public interface INamed + { + string Name { get; } + } + + public sealed record User(string Name) : INamed; + + [UnionType] + public interface IDocument; + + [Authorize] + public sealed record Item(string Value) : IDocument; + + [Authorize(ApplyPolicy.AfterResolver)] + public sealed record ItemAfterResolver(string Value); + + [Authorize("TYPE_1")] + [Authorize("TYPE_2")] + public sealed record ItemWithPolicies(string Value); + + [ExtendObjectType] + public sealed class UserExtensions + { + [Authorize] + [BatchResolver] + public List Value([Parent] List users) + => users.ConvertAll(u => "value " + u.Name); + + [Authorize(ApplyPolicy.AfterResolver)] + [BatchResolver] + public List ValueAfterResolver([Parent] List users) + => users.ConvertAll(u => "value " + u.Name); + + [BatchResolver] + public List Item([Parent] List users) + => users.ConvertAll(u => new Item("value " + u.Name)); + + [BatchResolver] + public List ItemAfterResolver([Parent] List users) + => users.ConvertAll(u => new ItemAfterResolver("value " + u.Name)); + + [BatchResolver] + public List Document([Parent] List users) + => users.ConvertAll(u => new Item("value " + u.Name)); + + [Authorize] + [BatchResolver] + public List ParentNames([Parent] List users) + => users.ConvertAll(_ => string.Join(",", users.Select(u => u.Name))); + + [Authorize("FIELD_1")] + [Authorize("FIELD_2")] + public ItemWithPolicies? Regular([Parent] User user) + => new("value " + user.Name); + + [Authorize("FIELD_1")] + [Authorize("FIELD_2")] + [BatchResolver] + public List Batched([Parent] List users) + => users.ConvertAll(u => new ItemWithPolicies("value " + u.Name)); + } + + public sealed class NamedResolvers + { + [BatchResolver] + public List Value([Parent] List users) + => users.ConvertAll(u => "value " + u.Name); + } + + private sealed class AuthHandler( + Func resolver) + : IAuthorizationHandler + { + public ValueTask AuthorizeAsync( + IMiddlewareContext context, + AuthorizeDirective directive, + CancellationToken cancellationToken = default) + => new(resolver(context, directive)); + + public ValueTask AuthorizeAsync( + AuthorizationContext context, + IReadOnlyList directives, + CancellationToken cancellationToken = default) + => new(AuthorizeResult.Allowed); + } +}