diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs
index 5ea9968f9ab..b57f8264ba5 100644
--- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs
+++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Formatters/DefaultHttpResponseFormatter.cs
@@ -389,6 +389,15 @@ private async ValueTask FormatInternalAsync(
response.Headers.Vary = varyHeaderValue;
}
+ if (statusCode is (int)HttpStatusCode.Unauthorized
+ && result.ContextData.TryGetValue(
+ ExecutionContextData.WwwAuthenticateHeaderValue,
+ out var challengeValue)
+ && challengeValue is string challengeHeaderValue)
+ {
+ response.Headers.WWWAuthenticate = challengeHeaderValue;
+ }
+
OnWriteResponseHeaders(operationResult, format, response.Headers);
await format.Formatter.FormatAsync(
diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/ExecutionContextData.cs b/src/HotChocolate/Core/src/Execution.Abstractions/ExecutionContextData.cs
index f25d98e68b3..5da42e09c7b 100644
--- a/src/HotChocolate/Core/src/Execution.Abstractions/ExecutionContextData.cs
+++ b/src/HotChocolate/Core/src/Execution.Abstractions/ExecutionContextData.cs
@@ -63,4 +63,9 @@ public static class ExecutionContextData
/// The key to get the Vary header value from the context data.
///
public const string VaryHeaderValue = "HotChocolate.Caching.VaryHeaderValue";
+
+ ///
+ /// The key to get the WWW-Authenticate header value from the context data.
+ ///
+ public const string WwwAuthenticateHeaderValue = "HotChocolate.Execution.Transport.WwwAuthenticateHeaderValue";
}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Authenticated.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Authenticated.cs
new file mode 100644
index 00000000000..1772df46525
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Authenticated.cs
@@ -0,0 +1,31 @@
+namespace HotChocolate.Types.Composite;
+
+///
+/// The @authenticated directive declares that accessing the annotated member requires an
+/// authenticated client.
+///
+[DirectiveType(
+ DirectiveNames.Authenticated.Name,
+ DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Object
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Enum
+ | DirectiveLocation.Scalar,
+ IsRepeatable = false)]
+[GraphQLDescription("The @authenticated directive requires the client to be authenticated.")]
+public sealed class Authenticated
+{
+ private const string Name = $"@{DirectiveNames.Authenticated.Name}";
+
+ private Authenticated()
+ {
+ }
+
+ ///
+ public override string ToString() => Name;
+
+ ///
+ /// The singleton instance of the directive.
+ ///
+ public static Authenticated Instance { get; } = new();
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthenticatedAttribute.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthenticatedAttribute.cs
new file mode 100644
index 00000000000..0e355bd6d47
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthenticatedAttribute.cs
@@ -0,0 +1,58 @@
+using System.Reflection;
+using HotChocolate.Types.Descriptors;
+using HotChocolate.Utilities;
+
+namespace HotChocolate.Types.Composite;
+
+///
+/// Applies the @authenticated directive to the annotated member.
+///
+[AttributeUsage(
+ AttributeTargets.Class
+ | AttributeTargets.Struct
+ | AttributeTargets.Interface
+ | AttributeTargets.Enum
+ | AttributeTargets.Method
+ | AttributeTargets.Property,
+ AllowMultiple = false)]
+public sealed class AuthenticatedAttribute : DescriptorAttribute
+{
+ protected internal override void TryConfigure(
+ IDescriptorContext context,
+ IDescriptor descriptor,
+ ICustomAttributeProvider? attributeProvider)
+ {
+ switch (descriptor)
+ {
+ case IObjectTypeDescriptor desc:
+ desc.Authenticated();
+ break;
+
+ case IObjectFieldDescriptor desc:
+ desc.Authenticated();
+ break;
+
+ case IInterfaceTypeDescriptor desc:
+ desc.Authenticated();
+ break;
+
+ case IInterfaceFieldDescriptor desc:
+ desc.Authenticated();
+ break;
+
+ case IEnumTypeDescriptor desc:
+ desc.Authenticated();
+ break;
+
+ case IScalarTypeDescriptor desc:
+ desc.Authenticated();
+ break;
+
+ default:
+ throw ThrowHelper.AuthorizationDirective_UnsupportedDescriptor(
+ DirectiveNames.Authenticated.Name,
+ attributeProvider,
+ descriptor);
+ }
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthenticatedDescriptorExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthenticatedDescriptorExtensions.cs
new file mode 100644
index 00000000000..7df60ade057
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthenticatedDescriptorExtensions.cs
@@ -0,0 +1,115 @@
+namespace HotChocolate.Types.Composite;
+
+///
+/// Provides descriptor extensions to apply the @authenticated directive.
+///
+public static class AuthenticatedDescriptorExtensions
+{
+ ///
+ /// Applies the @authenticated directive to the object type.
+ ///
+ ///
+ /// The object type descriptor to apply the directive to.
+ ///
+ ///
+ /// The object type descriptor with the directive applied.
+ ///
+ ///
+ /// The is null.
+ ///
+ public static IObjectTypeDescriptor Authenticated(this IObjectTypeDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ return descriptor.Directive(Composite.Authenticated.Instance);
+ }
+
+ ///
+ /// Applies the @authenticated directive to the object field.
+ ///
+ ///
+ /// The object field descriptor to apply the directive to.
+ ///
+ ///
+ /// The object field descriptor with the directive applied.
+ ///
+ ///
+ /// The is null.
+ ///
+ public static IObjectFieldDescriptor Authenticated(this IObjectFieldDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ return descriptor.Directive(Composite.Authenticated.Instance);
+ }
+
+ ///
+ /// Applies the @authenticated directive to the interface type.
+ ///
+ ///
+ /// The interface type descriptor to apply the directive to.
+ ///
+ ///
+ /// The interface type descriptor with the directive applied.
+ ///
+ ///
+ /// The is null.
+ ///
+ public static IInterfaceTypeDescriptor Authenticated(this IInterfaceTypeDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ return descriptor.Directive(Composite.Authenticated.Instance);
+ }
+
+ ///
+ /// Applies the @authenticated directive to the interface field.
+ ///
+ ///
+ /// The interface field descriptor to apply the directive to.
+ ///
+ ///
+ /// The interface field descriptor with the directive applied.
+ ///
+ ///
+ /// The is null.
+ ///
+ public static IInterfaceFieldDescriptor Authenticated(this IInterfaceFieldDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ return descriptor.Directive(Composite.Authenticated.Instance);
+ }
+
+ ///
+ /// Applies the @authenticated directive to the enum type.
+ ///
+ ///
+ /// The enum type descriptor to apply the directive to.
+ ///
+ ///
+ /// The enum type descriptor with the directive applied.
+ ///
+ ///
+ /// The is null.
+ ///
+ public static IEnumTypeDescriptor Authenticated(this IEnumTypeDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ return descriptor.Directive(Composite.Authenticated.Instance);
+ }
+
+ ///
+ /// Applies the @authenticated directive to the scalar type.
+ ///
+ ///
+ /// The scalar type descriptor to apply the directive to.
+ ///
+ ///
+ /// The scalar type descriptor with the directive applied.
+ ///
+ ///
+ /// The is null.
+ ///
+ public static IScalarTypeDescriptor Authenticated(this IScalarTypeDescriptor descriptor)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ return descriptor.Directive(Composite.Authenticated.Instance);
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthorizationGroups.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthorizationGroups.cs
new file mode 100644
index 00000000000..76129565d92
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/AuthorizationGroups.cs
@@ -0,0 +1,109 @@
+using HotChocolate.Internal;
+using HotChocolate.Types.Descriptors;
+using HotChocolate.Types.Descriptors.Configurations;
+using HotChocolate.Utilities;
+
+namespace HotChocolate.Types.Composite;
+
+internal static class AuthorizationGroups
+{
+ public static IReadOnlyList CreateGroup(
+ string directiveName,
+ IEnumerable values,
+ string paramName)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+
+ var set = new SortedSet(StringComparer.Ordinal);
+
+ foreach (var value in values)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ throw ThrowHelper.AuthorizationDirective_ValueIsEmpty(directiveName, paramName);
+ }
+
+ set.Add(value);
+ }
+
+ if (set.Count == 0)
+ {
+ throw ThrowHelper.AuthorizationDirective_GroupIsEmpty(directiveName, paramName);
+ }
+
+ return [.. set];
+ }
+
+ public static IReadOnlyList> Canonicalize(
+ string directiveName,
+ IEnumerable> groups,
+ string paramName)
+ {
+ ArgumentNullException.ThrowIfNull(groups);
+
+ var result = new List>();
+
+ foreach (var group in groups)
+ {
+ var canonical = CreateGroup(directiveName, group, paramName);
+
+ if (!result.Exists(existing => existing.SequenceEqual(canonical)))
+ {
+ result.Add(canonical);
+ }
+ }
+
+ if (result.Count == 0)
+ {
+ throw ThrowHelper.AuthorizationDirective_GroupIsEmpty(directiveName, paramName);
+ }
+
+ result.Sort(CompareGroups);
+ return result;
+ }
+
+ public static void AddGroup(
+ IDirectiveConfigurationProvider configuration,
+ ITypeInspector typeInspector,
+ string directiveName,
+ string[] values,
+ string paramName,
+ Func>> getGroups,
+ Func>, TDirective> create)
+ where TDirective : class
+ {
+ ArgumentNullException.ThrowIfNull(values);
+
+ var group = CreateGroup(directiveName, values, paramName);
+ var groups = new List>();
+
+ for (var i = configuration.Directives.Count - 1; i >= 0; i--)
+ {
+ if (configuration.Directives[i].Value is TDirective existing)
+ {
+ groups.AddRange(getGroups(existing));
+ configuration.Directives.RemoveAt(i);
+ }
+ }
+
+ groups.Add(group);
+ configuration.AddDirective(create(groups), typeInspector);
+ }
+
+ private static int CompareGroups(IReadOnlyList x, IReadOnlyList y)
+ {
+ var length = Math.Min(x.Count, y.Count);
+
+ for (var i = 0; i < length; i++)
+ {
+ var result = string.CompareOrdinal(x[i], y[i]);
+
+ if (result != 0)
+ {
+ return result;
+ }
+ }
+
+ return x.Count.CompareTo(y.Count);
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Policy.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Policy.cs
new file mode 100644
index 00000000000..62f2c74b24e
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/Policy.cs
@@ -0,0 +1,50 @@
+namespace HotChocolate.Types.Composite;
+
+///
+/// The @policy directive declares the policy groups of which at least one must be fully
+/// satisfied to access the annotated member.
+///
+[DirectiveType(
+ DirectiveNames.Policy.Name,
+ DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Object
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Enum
+ | DirectiveLocation.Scalar,
+ IsRepeatable = false)]
+[GraphQLDescription(
+ """
+ The @policy directive requires the client to satisfy all policies of at least one of
+ the policy groups.
+ """)]
+public sealed class Policy
+{
+ private const string Name = $"@{DirectiveNames.Policy.Name}";
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The policy groups. All policies of a group are required together, and any single group
+ /// is sufficient. Groups are stored in canonical order.
+ ///
+ public Policy(IReadOnlyList> policies)
+ {
+ Policies = AuthorizationGroups.Canonicalize(
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies));
+ }
+
+ ///
+ /// Gets the policy groups in canonical order.
+ ///
+ [GraphQLName(DirectiveNames.Policy.Arguments.Policies)]
+ [GraphQLDescription("The policy groups of which at least one must be fully satisfied.")]
+ [GraphQLType>>>>>]
+ public IReadOnlyList> Policies { get; }
+
+ ///
+ public override string ToString()
+ => $"{Name}(policies: [{string.Join(", ", Policies.Select(g => $"[{string.Join(", ", g.Select(s => $"\"{s}\""))}]"))}])";
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/PolicyAttribute.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/PolicyAttribute.cs
new file mode 100644
index 00000000000..681a3864832
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/PolicyAttribute.cs
@@ -0,0 +1,82 @@
+using System.Reflection;
+using HotChocolate.Types.Descriptors;
+using HotChocolate.Utilities;
+
+namespace HotChocolate.Types.Composite;
+
+///
+/// Applies one @policy group to the annotated member. Repeat the attribute to add further
+/// alternative groups.
+///
+[AttributeUsage(
+ AttributeTargets.Class
+ | AttributeTargets.Struct
+ | AttributeTargets.Interface
+ | AttributeTargets.Enum
+ | AttributeTargets.Method
+ | AttributeTargets.Property,
+ AllowMultiple = true)]
+public sealed class PolicyAttribute : DescriptorAttribute
+{
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ public PolicyAttribute(params string[] policies)
+ {
+ Policies = policies ?? throw new ArgumentNullException(nameof(policies));
+ }
+
+ ///
+ /// Gets the policies of this group.
+ ///
+ public IReadOnlyList Policies { get; }
+
+ protected internal override void TryConfigure(
+ IDescriptorContext context,
+ IDescriptor descriptor,
+ ICustomAttributeProvider? attributeProvider)
+ {
+ var values = new string[Policies.Count];
+
+ for (var i = 0; i < values.Length; i++)
+ {
+ values[i] = Policies[i];
+ }
+
+ switch (descriptor)
+ {
+ case IObjectTypeDescriptor desc:
+ desc.Policy(values);
+ break;
+
+ case IObjectFieldDescriptor desc:
+ desc.Policy(values);
+ break;
+
+ case IInterfaceTypeDescriptor desc:
+ desc.Policy(values);
+ break;
+
+ case IInterfaceFieldDescriptor desc:
+ desc.Policy(values);
+ break;
+
+ case IEnumTypeDescriptor desc:
+ desc.Policy(values);
+ break;
+
+ case IScalarTypeDescriptor desc:
+ desc.Policy(values);
+ break;
+
+ default:
+ throw ThrowHelper.AuthorizationDirective_UnsupportedDescriptor(
+ DirectiveNames.Policy.Name,
+ attributeProvider,
+ descriptor);
+ }
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/PolicyDescriptorExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/PolicyDescriptorExtensions.cs
new file mode 100644
index 00000000000..e812a9a1211
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/PolicyDescriptorExtensions.cs
@@ -0,0 +1,229 @@
+namespace HotChocolate.Types.Composite;
+
+///
+/// Provides descriptor extensions to apply the @policy directive.
+///
+public static class PolicyDescriptorExtensions
+{
+ ///
+ /// Adds one @policy group to the object type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The object type descriptor to apply the directive to.
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ ///
+ /// The object type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IObjectTypeDescriptor Policy(
+ this IObjectTypeDescriptor descriptor,
+ params string[] policies)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(policies);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies),
+ static directive => directive.Policies,
+ static groups => new Policy(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @policy group to the object field. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The object field descriptor to apply the directive to.
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ ///
+ /// The object field descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IObjectFieldDescriptor Policy(
+ this IObjectFieldDescriptor descriptor,
+ params string[] policies)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(policies);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies),
+ static directive => directive.Policies,
+ static groups => new Policy(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @policy group to the interface type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The interface type descriptor to apply the directive to.
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ ///
+ /// The interface type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IInterfaceTypeDescriptor Policy(
+ this IInterfaceTypeDescriptor descriptor,
+ params string[] policies)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(policies);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies),
+ static directive => directive.Policies,
+ static groups => new Policy(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @policy group to the interface field. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The interface field descriptor to apply the directive to.
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ ///
+ /// The interface field descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IInterfaceFieldDescriptor Policy(
+ this IInterfaceFieldDescriptor descriptor,
+ params string[] policies)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(policies);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies),
+ static directive => directive.Policies,
+ static groups => new Policy(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @policy group to the enum type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The enum type descriptor to apply the directive to.
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ ///
+ /// The enum type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IEnumTypeDescriptor Policy(
+ this IEnumTypeDescriptor descriptor,
+ params string[] policies)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(policies);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies),
+ static directive => directive.Policies,
+ static groups => new Policy(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @policy group to the scalar type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The scalar type descriptor to apply the directive to.
+ ///
+ ///
+ /// The policies that are required together.
+ ///
+ ///
+ /// The scalar type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IScalarTypeDescriptor Policy(
+ this IScalarTypeDescriptor descriptor,
+ params string[] policies)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(policies);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.Policy.Name,
+ policies,
+ nameof(policies),
+ static directive => directive.Policies,
+ static groups => new Policy(groups));
+ return descriptor;
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopes.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopes.cs
new file mode 100644
index 00000000000..2e49a92d842
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopes.cs
@@ -0,0 +1,50 @@
+namespace HotChocolate.Types.Composite;
+
+///
+/// The @requiresScopes directive declares the scope groups of which at least one must be fully
+/// granted to access the annotated member.
+///
+[DirectiveType(
+ DirectiveNames.RequiresScopes.Name,
+ DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Object
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Enum
+ | DirectiveLocation.Scalar,
+ IsRepeatable = false)]
+[GraphQLDescription(
+ """
+ The @requiresScopes directive requires the client to be granted all scopes of at least
+ one of the scope groups.
+ """)]
+public sealed class RequiresScopes
+{
+ private const string Name = $"@{DirectiveNames.RequiresScopes.Name}";
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The scope groups. All scopes of a group are required together, and any single group
+ /// is sufficient. Groups are stored in canonical order.
+ ///
+ public RequiresScopes(IReadOnlyList> scopes)
+ {
+ Scopes = AuthorizationGroups.Canonicalize(
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes));
+ }
+
+ ///
+ /// Gets the scope groups in canonical order.
+ ///
+ [GraphQLName(DirectiveNames.RequiresScopes.Arguments.Scopes)]
+ [GraphQLDescription("The scope groups of which at least one must be fully granted.")]
+ [GraphQLType>>>>>]
+ public IReadOnlyList> Scopes { get; }
+
+ ///
+ public override string ToString()
+ => $"{Name}(scopes: [{string.Join(", ", Scopes.Select(g => $"[{string.Join(", ", g.Select(s => $"\"{s}\""))}]"))}])";
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopesAttribute.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopesAttribute.cs
new file mode 100644
index 00000000000..6674cb98d31
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopesAttribute.cs
@@ -0,0 +1,82 @@
+using System.Reflection;
+using HotChocolate.Types.Descriptors;
+using HotChocolate.Utilities;
+
+namespace HotChocolate.Types.Composite;
+
+///
+/// Applies one @requiresScopes group to the annotated member. Repeat the attribute to add further
+/// alternative groups.
+///
+[AttributeUsage(
+ AttributeTargets.Class
+ | AttributeTargets.Struct
+ | AttributeTargets.Interface
+ | AttributeTargets.Enum
+ | AttributeTargets.Method
+ | AttributeTargets.Property,
+ AllowMultiple = true)]
+public sealed class RequiresScopesAttribute : DescriptorAttribute
+{
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ public RequiresScopesAttribute(params string[] scopes)
+ {
+ Scopes = scopes ?? throw new ArgumentNullException(nameof(scopes));
+ }
+
+ ///
+ /// Gets the scopes of this group.
+ ///
+ public IReadOnlyList Scopes { get; }
+
+ protected internal override void TryConfigure(
+ IDescriptorContext context,
+ IDescriptor descriptor,
+ ICustomAttributeProvider? attributeProvider)
+ {
+ var values = new string[Scopes.Count];
+
+ for (var i = 0; i < values.Length; i++)
+ {
+ values[i] = Scopes[i];
+ }
+
+ switch (descriptor)
+ {
+ case IObjectTypeDescriptor desc:
+ desc.RequiresScopes(values);
+ break;
+
+ case IObjectFieldDescriptor desc:
+ desc.RequiresScopes(values);
+ break;
+
+ case IInterfaceTypeDescriptor desc:
+ desc.RequiresScopes(values);
+ break;
+
+ case IInterfaceFieldDescriptor desc:
+ desc.RequiresScopes(values);
+ break;
+
+ case IEnumTypeDescriptor desc:
+ desc.RequiresScopes(values);
+ break;
+
+ case IScalarTypeDescriptor desc:
+ desc.RequiresScopes(values);
+ break;
+
+ default:
+ throw ThrowHelper.AuthorizationDirective_UnsupportedDescriptor(
+ DirectiveNames.RequiresScopes.Name,
+ attributeProvider,
+ descriptor);
+ }
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopesDescriptorExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopesDescriptorExtensions.cs
new file mode 100644
index 00000000000..9f77e2105fa
--- /dev/null
+++ b/src/HotChocolate/Core/src/Types/Types/Composite/Directives/RequiresScopesDescriptorExtensions.cs
@@ -0,0 +1,229 @@
+namespace HotChocolate.Types.Composite;
+
+///
+/// Provides descriptor extensions to apply the @requiresScopes directive.
+///
+public static class RequiresScopesDescriptorExtensions
+{
+ ///
+ /// Adds one @requiresScopes group to the object type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The object type descriptor to apply the directive to.
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ ///
+ /// The object type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IObjectTypeDescriptor RequiresScopes(
+ this IObjectTypeDescriptor descriptor,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes),
+ static directive => directive.Scopes,
+ static groups => new RequiresScopes(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @requiresScopes group to the object field. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The object field descriptor to apply the directive to.
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ ///
+ /// The object field descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IObjectFieldDescriptor RequiresScopes(
+ this IObjectFieldDescriptor descriptor,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes),
+ static directive => directive.Scopes,
+ static groups => new RequiresScopes(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @requiresScopes group to the interface type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The interface type descriptor to apply the directive to.
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ ///
+ /// The interface type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IInterfaceTypeDescriptor RequiresScopes(
+ this IInterfaceTypeDescriptor descriptor,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes),
+ static directive => directive.Scopes,
+ static groups => new RequiresScopes(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @requiresScopes group to the interface field. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The interface field descriptor to apply the directive to.
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ ///
+ /// The interface field descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IInterfaceFieldDescriptor RequiresScopes(
+ this IInterfaceFieldDescriptor descriptor,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes),
+ static directive => directive.Scopes,
+ static groups => new RequiresScopes(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @requiresScopes group to the enum type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The enum type descriptor to apply the directive to.
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ ///
+ /// The enum type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IEnumTypeDescriptor RequiresScopes(
+ this IEnumTypeDescriptor descriptor,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes),
+ static directive => directive.Scopes,
+ static groups => new RequiresScopes(groups));
+ return descriptor;
+ }
+
+ ///
+ /// Adds one @requiresScopes group to the scalar type. Calling the method again adds an alternative group.
+ ///
+ ///
+ /// The scalar type descriptor to apply the directive to.
+ ///
+ ///
+ /// The scopes that are required together.
+ ///
+ ///
+ /// The scalar type descriptor with the directive applied.
+ ///
+ ///
+ /// The or is null.
+ ///
+ ///
+ /// The group is empty or contains a null or empty value.
+ ///
+ public static IScalarTypeDescriptor RequiresScopes(
+ this IScalarTypeDescriptor descriptor,
+ params string[] scopes)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(scopes);
+
+ var extension = descriptor.Extend();
+ AuthorizationGroups.AddGroup(
+ extension.Configuration,
+ extension.Context.TypeInspector,
+ DirectiveNames.RequiresScopes.Name,
+ scopes,
+ nameof(scopes),
+ static directive => directive.Scopes,
+ static groups => new RequiresScopes(groups));
+ return descriptor;
+ }
+}
diff --git a/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs b/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs
index 6f43481e890..d9385f209ab 100644
--- a/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs
+++ b/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs
@@ -742,4 +742,31 @@ public static LeafCoercionException RegexType_InvalidFormat(
.Build(),
type);
}
+
+ public static ArgumentException AuthorizationDirective_GroupIsEmpty(
+ string directiveName,
+ string paramName)
+ => new(
+ $"The @{directiveName} directive requires at least one value per group.",
+ paramName);
+
+ public static ArgumentException AuthorizationDirective_ValueIsEmpty(
+ string directiveName,
+ string paramName)
+ => new(
+ $"The @{directiveName} directive does not allow null or empty values.",
+ paramName);
+
+ public static SchemaException AuthorizationDirective_UnsupportedDescriptor(
+ string directiveName,
+ ICustomAttributeProvider? attributeProvider,
+ IDescriptor descriptor)
+ => new(
+ SchemaErrorBuilder.New()
+ .SetMessage(
+ $"The @{directiveName} directive is only supported on object types, "
+ + "object fields, interface types, interface fields, enum types and scalar types.")
+ .SetExtension("member", attributeProvider)
+ .SetExtension("descriptor", descriptor)
+ .Build());
}
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/AuthorizationDirectiveTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/AuthorizationDirectiveTests.cs
new file mode 100644
index 00000000000..eca59f9539b
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/AuthorizationDirectiveTests.cs
@@ -0,0 +1,466 @@
+using HotChocolate.Execution;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Types.Composite;
+
+public static class AuthorizationDirectiveTests
+{
+ [Fact]
+ public static async Task Authenticated_Should_RenderOnEveryLocation_When_AppliedByAttribute()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .AddQueryType()
+ .AddType()
+ .AddType()
+ .AddType();
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task Authenticated_Should_RenderOnEveryLocation_When_AppliedByDescriptor()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field").Type().Authenticated();
+ d.Field("thing").Type("Thing");
+ d.Field("paint").Type("Paint");
+ d.Field("custom").Type("Custom");
+ })
+ .AddType(new InterfaceType(d =>
+ {
+ d.Name("Thing");
+ d.Field("name").Type().Authenticated();
+ d.Authenticated();
+ }))
+ .AddType(new ObjectType(d =>
+ {
+ d.Name("Impl");
+ d.Implements("Thing");
+ d.Field("name").Type().Authenticated();
+ d.Authenticated();
+ }))
+ .AddType(new EnumType(d =>
+ {
+ d.Name("Paint");
+ d.Value("RED");
+ d.Authenticated();
+ }))
+ .AddType(new ConfigurableScalarType("Custom", d => d.Authenticated()));
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByAttribute()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType()
+ .AddType()
+ .AddType()
+ .AddType();
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByDescriptor()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field").Type().RequiresScopes("write", "read").RequiresScopes("admin");
+ d.Field("thing").Type("Thing");
+ d.Field("paint").Type("Paint");
+ d.Field("custom").Type("Custom");
+ })
+ .AddType(new InterfaceType(d =>
+ {
+ d.Name("Thing");
+ d.Field("name").Type().RequiresScopes("write", "read").RequiresScopes("admin");
+ d.RequiresScopes("write", "read").RequiresScopes("admin");
+ }))
+ .AddType(new ObjectType(d =>
+ {
+ d.Name("Impl");
+ d.Implements("Thing");
+ d.Field("name").Type().RequiresScopes("write", "read").RequiresScopes("admin");
+ d.RequiresScopes("write", "read").RequiresScopes("admin");
+ }))
+ .AddType(new EnumType(d =>
+ {
+ d.Name("Paint");
+ d.Value("RED");
+ d.RequiresScopes("write", "read").RequiresScopes("admin");
+ }))
+ .AddType(new ConfigurableScalarType("Custom", d => d.RequiresScopes("write", "read").RequiresScopes("admin")));
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task Policy_Should_RenderOnEveryLocation_When_AppliedByAttribute()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType()
+ .AddType()
+ .AddType()
+ .AddType();
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task Policy_Should_RenderOnEveryLocation_When_AppliedByDescriptor()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field").Type().Policy("p2", "p1").Policy("p3");
+ d.Field("thing").Type("Thing");
+ d.Field("paint").Type("Paint");
+ d.Field("custom").Type("Custom");
+ })
+ .AddType(new InterfaceType(d =>
+ {
+ d.Name("Thing");
+ d.Field("name").Type().Policy("p2", "p1").Policy("p3");
+ d.Policy("p2", "p1").Policy("p3");
+ }))
+ .AddType(new ObjectType(d =>
+ {
+ d.Name("Impl");
+ d.Implements("Thing");
+ d.Field("name").Type().Policy("p2", "p1").Policy("p3");
+ d.Policy("p2", "p1").Policy("p3");
+ }))
+ .AddType(new EnumType(d =>
+ {
+ d.Name("Paint");
+ d.Value("RED");
+ d.Policy("p2", "p1").Policy("p3");
+ }))
+ .AddType(new ConfigurableScalarType("Custom", d => d.Policy("p2", "p1").Policy("p3")));
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task RequiresScopes_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field")
+ .Type()
+ .RequiresScopes("b", "a", "a")
+ .RequiresScopes("c")
+ .RequiresScopes("a", "b");
+ });
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task Policy_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field")
+ .Type()
+ .Policy("b", "a", "a")
+ .Policy("c")
+ .Policy("a", "b");
+ });
+
+ // act
+ var schema = await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ schema.MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public static async Task RequiresScopes_Should_Throw_When_GroupIsEmpty()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field").Type().RequiresScopes();
+ });
+
+ // act
+ async Task Act() => await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ var exception = await Assert.ThrowsAsync(Act);
+ exception.Errors[0].Message.MatchInlineSnapshot(
+ "The @requiresScopes directive requires at least one value per group. (Parameter 'scopes')");
+ }
+
+ [Fact]
+ public static async Task Policy_Should_Throw_When_PolicyNameIsEmpty()
+ {
+ // arrange
+ var builder =
+ new ServiceCollection()
+ .AddGraphQL()
+ .UseField(x => x)
+ .AddQueryType(d =>
+ {
+ d.Name("Query");
+ d.Field("field").Type().Policy("");
+ });
+
+ // act
+ async Task Act() => await builder.BuildSchemaAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ // assert
+ var exception = await Assert.ThrowsAsync(Act);
+ exception.Errors[0].Message.MatchInlineSnapshot(
+ "The @policy directive does not allow null or empty values. (Parameter 'policies')");
+ }
+
+ private sealed class ConfigurableScalarType : StringType
+ {
+ private readonly Action _configure;
+
+ public ConfigurableScalarType(string name, Action configure)
+ : base(name)
+ {
+ _configure = configure;
+ }
+
+ protected override void Configure(IScalarTypeDescriptor descriptor)
+ => _configure(descriptor);
+ }
+
+ private static class AuthTypes
+ {
+ public class Query
+ {
+ [Authenticated]
+ public string? Field() => null;
+
+ public IThing? Thing() => null;
+
+ public Paint? Paint() => null;
+
+ [GraphQLType]
+ public string? Custom() => null;
+ }
+
+ [Authenticated]
+ [InterfaceType("Thing")]
+ public interface IThing
+ {
+ [Authenticated]
+ string? Name { get; }
+ }
+
+ public class ThingType : InterfaceType;
+
+ [Authenticated]
+ public class Impl : IThing
+ {
+ [Authenticated]
+ public string? Name => null;
+ }
+
+ [Authenticated]
+ public enum Paint
+ {
+ Red
+ }
+
+ [Authenticated]
+ public class CustomType : StringType
+ {
+ public CustomType()
+ : base("Custom")
+ {
+ }
+ }
+ }
+
+ private static class ScopesTypes
+ {
+ public class Query
+ {
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ public string? Field() => null;
+
+ public IThing? Thing() => null;
+
+ public Paint? Paint() => null;
+
+ [GraphQLType]
+ public string? Custom() => null;
+ }
+
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ [InterfaceType("Thing")]
+ public interface IThing
+ {
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ string? Name { get; }
+ }
+
+ public class ThingType : InterfaceType;
+
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ public class Impl : IThing
+ {
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ public string? Name => null;
+ }
+
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ public enum Paint
+ {
+ Red
+ }
+
+ [RequiresScopes("write", "read")]
+ [RequiresScopes("admin")]
+ public class CustomType : StringType
+ {
+ public CustomType()
+ : base("Custom")
+ {
+ }
+ }
+ }
+
+ private static class PolTypes
+ {
+ public class Query
+ {
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ public string? Field() => null;
+
+ public IThing? Thing() => null;
+
+ public Paint? Paint() => null;
+
+ [GraphQLType]
+ public string? Custom() => null;
+ }
+
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ [InterfaceType("Thing")]
+ public interface IThing
+ {
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ string? Name { get; }
+ }
+
+ public class ThingType : InterfaceType;
+
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ public class Impl : IThing
+ {
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ public string? Name => null;
+ }
+
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ public enum Paint
+ {
+ Red
+ }
+
+ [Policy("p2", "p1")]
+ [Policy("p3")]
+ public class CustomType : StringType
+ {
+ public CustomType()
+ : base("Custom")
+ {
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Authenticated_Should_RenderOnEveryLocation_When_AppliedByAttribute.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Authenticated_Should_RenderOnEveryLocation_When_AppliedByAttribute.md
new file mode 100644
index 00000000000..6ee2144932a
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Authenticated_Should_RenderOnEveryLocation_When_AppliedByAttribute.md
@@ -0,0 +1,36 @@
+# Authenticated_Should_RenderOnEveryLocation_When_AppliedByAttribute
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @authenticated
+ thing: Thing
+ paint: Paint
+ custom: Custom
+}
+
+type Impl implements Thing @authenticated {
+ name: String @authenticated
+}
+
+interface Thing @authenticated {
+ name: String @authenticated
+}
+
+enum Paint @authenticated {
+ RED
+}
+
+scalar Custom @authenticated
+
+"The @authenticated directive requires the client to be authenticated."
+directive @authenticated on
+ | SCALAR
+ | OBJECT
+ | FIELD_DEFINITION
+ | INTERFACE
+ | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Authenticated_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Authenticated_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md
new file mode 100644
index 00000000000..3e0737e1009
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Authenticated_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md
@@ -0,0 +1,36 @@
+# Authenticated_Should_RenderOnEveryLocation_When_AppliedByDescriptor
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @authenticated
+ thing: Thing
+ paint: Paint
+ custom: Custom
+}
+
+type Impl implements Thing @authenticated {
+ name: String @authenticated
+}
+
+interface Thing @authenticated {
+ name: String @authenticated
+}
+
+enum Paint @authenticated {
+ RED
+}
+
+scalar Custom @authenticated
+
+"The @authenticated directive requires the client to be authenticated."
+directive @authenticated on
+ | SCALAR
+ | OBJECT
+ | FIELD_DEFINITION
+ | INTERFACE
+ | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated.md
new file mode 100644
index 00000000000..f7382d288b0
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated.md
@@ -0,0 +1,20 @@
+# Policy_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @policy(policies: [["a", "b"], ["c"]])
+}
+
+"""
+The @policy directive requires the client to satisfy all policies of at least one of
+the policy groups.
+"""
+directive @policy(
+ "The policy groups of which at least one must be fully satisfied."
+ policies: [[String!]!]!
+) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderOnEveryLocation_When_AppliedByAttribute.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderOnEveryLocation_When_AppliedByAttribute.md
new file mode 100644
index 00000000000..340c5ae5099
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderOnEveryLocation_When_AppliedByAttribute.md
@@ -0,0 +1,37 @@
+# Policy_Should_RenderOnEveryLocation_When_AppliedByAttribute
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @policy(policies: [["p1", "p2"], ["p3"]])
+ thing: Thing
+ paint: Paint
+ custom: Custom
+}
+
+type Impl implements Thing @policy(policies: [["p1", "p2"], ["p3"]]) {
+ name: String @policy(policies: [["p1", "p2"], ["p3"]])
+}
+
+interface Thing @policy(policies: [["p1", "p2"], ["p3"]]) {
+ name: String @policy(policies: [["p1", "p2"], ["p3"]])
+}
+
+enum Paint @policy(policies: [["p1", "p2"], ["p3"]]) {
+ RED
+}
+
+scalar Custom @policy(policies: [["p1", "p2"], ["p3"]])
+
+"""
+The @policy directive requires the client to satisfy all policies of at least one of
+the policy groups.
+"""
+directive @policy(
+ "The policy groups of which at least one must be fully satisfied."
+ policies: [[String!]!]!
+) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md
new file mode 100644
index 00000000000..dbb3b7cea8d
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.Policy_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md
@@ -0,0 +1,37 @@
+# Policy_Should_RenderOnEveryLocation_When_AppliedByDescriptor
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @policy(policies: [["p1", "p2"], ["p3"]])
+ thing: Thing
+ paint: Paint
+ custom: Custom
+}
+
+type Impl implements Thing @policy(policies: [["p1", "p2"], ["p3"]]) {
+ name: String @policy(policies: [["p1", "p2"], ["p3"]])
+}
+
+interface Thing @policy(policies: [["p1", "p2"], ["p3"]]) {
+ name: String @policy(policies: [["p1", "p2"], ["p3"]])
+}
+
+enum Paint @policy(policies: [["p1", "p2"], ["p3"]]) {
+ RED
+}
+
+scalar Custom @policy(policies: [["p1", "p2"], ["p3"]])
+
+"""
+The @policy directive requires the client to satisfy all policies of at least one of
+the policy groups.
+"""
+directive @policy(
+ "The policy groups of which at least one must be fully satisfied."
+ policies: [[String!]!]!
+) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated.md
new file mode 100644
index 00000000000..79601455b1a
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated.md
@@ -0,0 +1,20 @@
+# RequiresScopes_Should_RenderCanonicalOrder_When_GroupsAreUnsortedAndDuplicated
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @requiresScopes(scopes: [["a", "b"], ["c"]])
+}
+
+"""
+The @requiresScopes directive requires the client to be granted all scopes of at least
+one of the scope groups.
+"""
+directive @requiresScopes(
+ "The scope groups of which at least one must be fully granted."
+ scopes: [[String!]!]!
+) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByAttribute.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByAttribute.md
new file mode 100644
index 00000000000..5892dda8c07
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByAttribute.md
@@ -0,0 +1,38 @@
+# RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByAttribute
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @requiresScopes(scopes: [["admin"], ["read", "write"]])
+ thing: Thing
+ paint: Paint
+ custom: Custom
+}
+
+type Impl implements Thing
+ @requiresScopes(scopes: [["admin"], ["read", "write"]]) {
+ name: String @requiresScopes(scopes: [["admin"], ["read", "write"]])
+}
+
+interface Thing @requiresScopes(scopes: [["admin"], ["read", "write"]]) {
+ name: String @requiresScopes(scopes: [["admin"], ["read", "write"]])
+}
+
+enum Paint @requiresScopes(scopes: [["admin"], ["read", "write"]]) {
+ RED
+}
+
+scalar Custom @requiresScopes(scopes: [["admin"], ["read", "write"]])
+
+"""
+The @requiresScopes directive requires the client to be granted all scopes of at least
+one of the scope groups.
+"""
+directive @requiresScopes(
+ "The scope groups of which at least one must be fully granted."
+ scopes: [[String!]!]!
+) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM
+```
diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md
new file mode 100644
index 00000000000..9ed12d7a29b
--- /dev/null
+++ b/src/HotChocolate/Core/test/Types.Tests/Types/Composite/__snapshots__/AuthorizationDirectiveTests.RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByDescriptor.md
@@ -0,0 +1,38 @@
+# RequiresScopes_Should_RenderOnEveryLocation_When_AppliedByDescriptor
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ field: String @requiresScopes(scopes: [["admin"], ["read", "write"]])
+ thing: Thing
+ paint: Paint
+ custom: Custom
+}
+
+type Impl implements Thing
+ @requiresScopes(scopes: [["admin"], ["read", "write"]]) {
+ name: String @requiresScopes(scopes: [["admin"], ["read", "write"]])
+}
+
+interface Thing @requiresScopes(scopes: [["admin"], ["read", "write"]]) {
+ name: String @requiresScopes(scopes: [["admin"], ["read", "write"]])
+}
+
+enum Paint @requiresScopes(scopes: [["admin"], ["read", "write"]]) {
+ RED
+}
+
+scalar Custom @requiresScopes(scopes: [["admin"], ["read", "write"]])
+
+"""
+The @requiresScopes directive requires the client to be granted all scopes of at least
+one of the scope groups.
+"""
+directive @requiresScopes(
+ "The scope groups of which at least one must be fully granted."
+ scopes: [[String!]!]!
+) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM
+```
diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs
index f238991100b..0c9a22080ca 100644
--- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs
+++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningBenchmark.cs
@@ -3,6 +3,7 @@
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Toolchains.InProcess.Emit;
+using HotChocolate.Fusion.Authorization;
using HotChocolate.Fusion.Execution.Nodes;
using HotChocolate.Fusion.Execution.Rewriters;
using HotChocolate.Fusion.Planning;
@@ -51,7 +52,10 @@ public void GlobalSetup()
var pool = new DefaultObjectPool>>(
new DefaultPooledObjectPolicy>>());
var compiler = new OperationCompiler(schema, pool);
- _planner = new OperationPlanner(schema, compiler);
+ _planner = new OperationPlanner(
+ schema,
+ compiler,
+ new PolicyResolver(new BuiltInPolicyProvider(), []));
_operation1 = LoadOperation(rewriter, s_query1Path);
_operation2 = LoadOperation(rewriter, s_query2Path);
diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs
index f54e96355b9..ae7fe25cb7f 100644
--- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs
+++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CorpusPlanningProbe.cs
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
+using HotChocolate.Fusion.Authorization;
using HotChocolate.Fusion.Execution.Nodes;
using HotChocolate.Fusion.Execution.Rewriters;
using HotChocolate.Fusion.Planning;
@@ -84,7 +85,11 @@ public static void Run(string[] args)
{
MaxPlanningTime = TimeSpan.FromMinutes(3)
};
- var planner = new OperationPlanner(schema, compiler, options);
+ var planner = new OperationPlanner(
+ schema,
+ compiler,
+ options,
+ new PolicyResolver(new BuiltInPolicyProvider(), []));
ProbeQuery(planner, rewriter, "Query1", query1Path);
ProbeQuery(planner, rewriter, "Query2", query2Path);
diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs
index ae273a49102..014629a6abb 100644
--- a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs
+++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/OperationPlannerBenchmark.cs
@@ -1,5 +1,6 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
+using HotChocolate.Fusion.Authorization;
using HotChocolate.Fusion.Execution.Nodes;
using HotChocolate.Fusion.Execution.Rewriters;
using HotChocolate.Fusion.Planning;
@@ -36,7 +37,10 @@ public void GlobalSetup()
new DefaultPooledObjectPolicy>>());
var operationCompiler = new OperationCompiler(schema, pool);
- _planner = new OperationPlanner(schema, operationCompiler);
+ _planner = new OperationPlanner(
+ schema,
+ operationCompiler,
+ new PolicyResolver(new BuiltInPolicyProvider(), []));
}
[Benchmark]
diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/AspNetCoreAuthenticationSchemeLookup.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/AspNetCoreAuthenticationSchemeLookup.cs
new file mode 100644
index 00000000000..e7531a32453
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/AspNetCoreAuthenticationSchemeLookup.cs
@@ -0,0 +1,43 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Authorization;
+using Microsoft.AspNetCore.Authentication;
+
+namespace HotChocolate.Fusion.AspNetCore;
+
+internal sealed class AspNetCoreAuthenticationSchemeLookup(IAuthenticationSchemeProvider? schemeProvider)
+ : IAuthenticationSchemeLookup
+{
+ private const string JwtBearerHandlerTypeName = "Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler";
+ private const string NegotiateHandlerTypeName = "Microsoft.AspNetCore.Authentication.Negotiate.NegotiateHandler";
+ private const string BearerChallenge = "Bearer";
+ private const string NegotiateChallenge = "Negotiate";
+
+ public async ValueTask> GetSchemeNamesAsync(CancellationToken cancellationToken)
+ {
+ if (schemeProvider is null)
+ {
+ return [];
+ }
+
+ var schemes = await schemeProvider.GetAllSchemesAsync().ConfigureAwait(false);
+
+ return [.. schemes.Select(scheme => scheme.Name)];
+ }
+
+ public async ValueTask GetChallengeAsync(string schemeName, CancellationToken cancellationToken)
+ {
+ if (schemeProvider is null)
+ {
+ return null;
+ }
+
+ var scheme = await schemeProvider.GetSchemeAsync(schemeName).ConfigureAwait(false);
+
+ return scheme?.HandlerType.FullName switch
+ {
+ JwtBearerHandlerTypeName => BearerChallenge,
+ NegotiateHandlerTypeName => NegotiateChallenge,
+ _ => null
+ };
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs
index 7ea5f14cddf..d6691e30bf2 100644
--- a/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.AspNetCore/DependencyInjection/FusionServerServiceCollectionExtensions.cs
@@ -8,8 +8,10 @@
using HotChocolate.AspNetCore.Subscriptions.Protocols.GraphQLOverWebSocket;
using HotChocolate.Execution;
using HotChocolate.Fusion.AspNetCore;
+using HotChocolate.Fusion.Authorization;
using HotChocolate.Fusion.Configuration;
using HotChocolate.Language;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
@@ -60,6 +62,10 @@ private static IFusionGatewayBuilder AddGraphQLGatewayServerCore(
{
sc.TryAddSingleton();
+ sc.TryAddSingleton(
+ _ => new AspNetCoreAuthenticationSchemeLookup(
+ applicationServices.GetService()));
+
sc.TryAddSingleton(
sp => DefaultHttpResponseFormatter.Create(
new HttpResponseFormatterOptions { HttpTransportVersion = HttpTransportVersion.Latest },
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs
index ce9d738db30..f2c4bf4f478 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaAnalyzer.cs
@@ -16,10 +16,7 @@ internal static class FederationSchemaAnalyzer
private static readonly HashSet s_unsupportedDirectives =
[
- FederationDirectiveNames.ComposeDirective,
- FederationDirectiveNames.Authenticated,
- FederationDirectiveNames.RequiresScopes,
- FederationDirectiveNames.Policy
+ FederationDirectiveNames.ComposeDirective
];
///
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs
index fa516d5dbcc..ec40524109f 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationSchemaTransformer.cs
@@ -93,6 +93,7 @@ public static CompositionResult Transform(string federationSdl)
}
SourceExternalFieldMetadata.Capture(schema);
+ TransformAuthDirectivesToFusion.Apply(schema);
RemoveFederationInfrastructure.Apply(schema);
GenerateLookupFields.Apply(schema);
RewriteKeyDirectives.Apply(schema);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationTypeNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationTypeNames.cs
index a5da21cd8ce..705fa92bbd4 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationTypeNames.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/FederationTypeNames.cs
@@ -7,4 +7,6 @@ internal static class FederationTypeNames
public const string Service = "_Service";
public const string FieldSet = "FieldSet";
public const string LegacyFieldSet = "_FieldSet";
+ public const string Scope = "Scope";
+ public const string Policy = "Policy";
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs
index e7ecacc0d00..7e84535dca5 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/RemoveFederationInfrastructure.cs
@@ -22,10 +22,7 @@ internal static class RemoveFederationInfrastructure
FederationDirectiveNames.Inaccessible,
FederationDirectiveNames.Override,
FederationDirectiveNames.Tag,
- FederationDirectiveNames.ComposeDirective,
- FederationDirectiveNames.Authenticated,
- FederationDirectiveNames.RequiresScopes,
- FederationDirectiveNames.Policy
+ FederationDirectiveNames.ComposeDirective
];
private static readonly HashSet s_federationScalarNames =
@@ -102,7 +99,7 @@ public static void Apply(MutableSchemaDefinition schema)
}
}
- private static HashSet CollectReferencedTypeNames(MutableSchemaDefinition schema)
+ internal static HashSet CollectReferencedTypeNames(MutableSchemaDefinition schema)
{
var referencedTypeNames = new HashSet(StringComparer.Ordinal);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/TransformAuthDirectivesToFusion.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/TransformAuthDirectivesToFusion.cs
new file mode 100644
index 00000000000..e9786e1fc29
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/ApolloFederation/TransformAuthDirectivesToFusion.cs
@@ -0,0 +1,312 @@
+using HotChocolate.Language;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Rewrites the Apollo Federation @authenticated, @requiresScopes and
+/// @policy directives, including renamed and namespaced spellings resolved through
+/// @link, into the Fusion source schema directives with [[String!]!] arguments.
+///
+internal static class TransformAuthDirectivesToFusion
+{
+ private const string DefaultNamespace = "federation";
+
+ private static readonly string[] s_directiveNames =
+ [
+ FederationDirectiveNames.Authenticated,
+ FederationDirectiveNames.RequiresScopes,
+ FederationDirectiveNames.Policy
+ ];
+
+ private static readonly string[] s_scalarNames =
+ [
+ FederationTypeNames.Scope,
+ FederationTypeNames.Policy
+ ];
+
+ ///
+ /// Applies the authorization directive transformation on the schema.
+ ///
+ ///
+ /// The mutable schema definition to transform in place.
+ ///
+ public static void Apply(MutableSchemaDefinition schema)
+ {
+ var vocabulary = Vocabulary.Resolve(schema);
+
+ foreach (var type in schema.Types)
+ {
+ switch (type)
+ {
+ case MutableComplexTypeDefinition complexType:
+ Rewrite(complexType.Directives, vocabulary);
+
+ foreach (var field in complexType.Fields)
+ {
+ Rewrite(field.Directives, vocabulary);
+ }
+
+ break;
+
+ case MutableEnumTypeDefinition enumType:
+ Rewrite(enumType.Directives, vocabulary);
+ break;
+
+ case MutableScalarTypeDefinition scalarType:
+ Rewrite(scalarType.Directives, vocabulary);
+ break;
+ }
+ }
+
+ RemoveApolloDefinitions(schema, vocabulary);
+ }
+
+ private static void Rewrite(DirectiveCollection directives, Vocabulary vocabulary)
+ {
+ if (directives.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var directive in directives.AsEnumerable().ToList())
+ {
+ if (!vocabulary.TryGetFusionName(directive.Name, out var fusionName))
+ {
+ continue;
+ }
+
+ var definition = FusionBuiltIns.SourceSchemaDirectives[fusionName];
+
+ directives.Replace(
+ directive,
+ fusionName switch
+ {
+ FederationDirectiveNames.RequiresScopes => CreateGroupedDirective(
+ definition,
+ directive,
+ WellKnownArgumentNames.Scopes),
+ FederationDirectiveNames.Policy => CreateGroupedDirective(
+ definition,
+ directive,
+ WellKnownArgumentNames.Policies),
+ _ => new Directive(definition)
+ });
+ }
+ }
+
+ private static Directive CreateGroupedDirective(
+ MutableDirectiveDefinition definition,
+ Directive source,
+ string argumentName)
+ {
+ // A missing argument is left missing, the source schema validation reports it.
+ return source.Arguments.TryGetValue(argumentName, out var value)
+ ? new Directive(
+ definition,
+ new ArgumentAssignment(argumentName, ToGroups(value)))
+ : new Directive(definition);
+ }
+
+ // GraphQL input coercion permits a single value wherever a list is expected. Values that
+ // cannot be read as strings are kept as written, the source schema validation reports them.
+ private static IValueNode ToGroups(IValueNode value)
+ {
+ return value switch
+ {
+ StringValueNode => new ListValueNode(new ListValueNode(value)),
+ ListValueNode list => ToGroups(list) ?? value,
+ _ => value
+ };
+ }
+
+ private static ListValueNode? ToGroups(ListValueNode list)
+ {
+ var groups = new List(list.Items.Count);
+
+ foreach (var item in list.Items)
+ {
+ switch (item)
+ {
+ case StringValueNode:
+ groups.Add(new ListValueNode(item));
+ break;
+
+ case ListValueNode group when group.Items.All(static i => i is StringValueNode):
+ groups.Add(group);
+ break;
+
+ default:
+ return null;
+ }
+ }
+
+ return new ListValueNode(groups);
+ }
+
+ private static void RemoveApolloDefinitions(
+ MutableSchemaDefinition schema,
+ Vocabulary vocabulary)
+ {
+ // The parser keeps the Fusion definition when a subgraph declares a directive under a
+ // Fusion name, so only definitions that are not the Fusion ones are Apollo's.
+ foreach (var name in vocabulary.DirectiveNames)
+ {
+ if (schema.DirectiveDefinitions.TryGetDirective(name, out var definition)
+ && !(FusionBuiltIns.SourceSchemaDirectives.TryGetValue(name, out var builtIn)
+ && ReferenceEquals(definition, builtIn)))
+ {
+ schema.DirectiveDefinitions.Remove(name);
+ }
+ }
+
+ var referencedTypeNames = RemoveFederationInfrastructure.CollectReferencedTypeNames(schema);
+
+ foreach (var name in vocabulary.ScalarNames)
+ {
+ if (!referencedTypeNames.Contains(name)
+ && schema.Types.TryGetType(name, out _))
+ {
+ schema.Types.Remove(name);
+ }
+ }
+ }
+
+ ///
+ /// The names under which a subgraph spells the Apollo authorization directives and scalars.
+ ///
+ private sealed class Vocabulary
+ {
+ private readonly Dictionary _fusionNames = new(StringComparer.Ordinal);
+ private readonly HashSet _scalarNames = new(StringComparer.Ordinal);
+
+ public IEnumerable DirectiveNames => _fusionNames.Keys;
+
+ public IEnumerable ScalarNames => _scalarNames;
+
+ public bool TryGetFusionName(string name, out string fusionName)
+ {
+ // A name that is not remapped by an import already is the Fusion directive.
+ if (_fusionNames.TryGetValue(name, out fusionName!))
+ {
+ return true;
+ }
+
+ fusionName = name;
+ return Array.IndexOf(s_directiveNames, name) >= 0;
+ }
+
+ public static Vocabulary Resolve(MutableSchemaDefinition schema)
+ {
+ var vocabulary = new Vocabulary();
+
+ // The unqualified names are Fusion's own, they are only listed so that their Apollo
+ // definitions are removed.
+ foreach (var name in s_directiveNames)
+ {
+ vocabulary._fusionNames[name] = name;
+ }
+
+ foreach (var link in schema.Directives[FederationDirectiveNames.Link])
+ {
+ if (!link.Arguments.TryGetValue("url", out var urlValue)
+ || urlValue is not StringValueNode url
+ || !url.Value.Contains(
+ FederationSchemaAnalyzer.FederationUrlPrefix,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var ns = link.Arguments.TryGetValue("as", out var asValue)
+ && asValue is StringValueNode { Value.Length: > 0 } alias
+ ? alias.Value
+ : DefaultNamespace;
+
+ vocabulary.AddNamespaced(ns);
+
+ if (link.Arguments.TryGetValue("import", out var importValue)
+ && importValue is ListValueNode imports)
+ {
+ foreach (var import in imports.Items)
+ {
+ vocabulary.AddImport(import);
+ }
+ }
+ }
+
+ return vocabulary;
+ }
+
+ private void AddNamespaced(string ns)
+ {
+ foreach (var name in s_directiveNames)
+ {
+ _fusionNames[$"{ns}__{name}"] = name;
+ }
+
+ foreach (var name in s_scalarNames)
+ {
+ _scalarNames.Add($"{ns}__{name}");
+ }
+ }
+
+ private void AddImport(IValueNode import)
+ {
+ string? name = null;
+ string? alias = null;
+
+ switch (import)
+ {
+ case StringValueNode importName:
+ name = importName.Value;
+ break;
+
+ case ObjectValueNode importObject:
+ foreach (var field in importObject.Fields)
+ {
+ if (field.Value is not StringValueNode fieldValue)
+ {
+ continue;
+ }
+
+ switch (field.Name.Value)
+ {
+ case "name":
+ name = fieldValue.Value;
+ break;
+
+ case "as":
+ alias = fieldValue.Value;
+ break;
+ }
+ }
+
+ break;
+ }
+
+ if (name is null)
+ {
+ return;
+ }
+
+ if (name.StartsWith('@'))
+ {
+ var directiveName = name[1..];
+
+ if (Array.IndexOf(s_directiveNames, directiveName) >= 0)
+ {
+ _fusionNames[(alias ?? name).TrimStart('@')] = directiveName;
+ }
+
+ return;
+ }
+
+ if (Array.IndexOf(s_scalarNames, name) >= 0)
+ {
+ _scalarNames.Add(alias ?? name);
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/AuthorizationInheritance.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/AuthorizationInheritance.cs
new file mode 100644
index 00000000000..8755136e988
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/AuthorizationInheritance.cs
@@ -0,0 +1,444 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.DirectiveMergers;
+using HotChocolate.Fusion.Directives;
+using HotChocolate.Fusion.Extensions;
+using HotChocolate.Fusion.Features;
+using HotChocolate.Fusion.Options;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames;
+
+namespace HotChocolate.Fusion;
+
+///
+/// Flattens the authorization requirements of the merged schema so that every field carries its
+/// complete requirement.
+///
+///
+/// Interface type requirements flow down to implementing types. A field combines its own, its
+/// declaring type and its return type requirements and shares the result with same-named fields
+/// across the implements relation.
+///
+internal static class AuthorizationInheritance
+{
+ private static readonly ImmutableSortedSet s_noSources =
+ ImmutableSortedSet.Create(StringComparer.Ordinal);
+
+ public static void Apply(
+ MutableSchemaDefinition mergedSchema,
+ MutableDirectiveDefinition fusionAuthorization,
+ IEnumerable sourceSchemas)
+ {
+ var sources = sourceSchemas.ToArray();
+ var typeNodes = new Dictionary(StringComparer.Ordinal);
+ var fieldNodes = new Dictionary<(string Type, string Field), Node>();
+ var complexTypes = new List();
+
+ foreach (var type in mergedSchema.Types)
+ {
+ switch (type)
+ {
+ case MutableComplexTypeDefinition complexType:
+ complexTypes.Add(complexType);
+ typeNodes[complexType.Name] = CreateNode(
+ new SchemaCoordinate(complexType.Name),
+ complexType.Directives,
+ GetDirectSources(sources, complexType.Name, null));
+
+ foreach (var field in complexType.Fields)
+ {
+ fieldNodes[(complexType.Name, field.Name)] = CreateNode(
+ new SchemaCoordinate(complexType.Name, field.Name, ofDirective: false),
+ field.Directives,
+ GetDirectSources(sources, complexType.Name, field.Name));
+ }
+
+ break;
+
+ case MutableEnumTypeDefinition or MutableScalarTypeDefinition:
+ typeNodes[type.Name] = CreateNode(
+ new SchemaCoordinate(type.Name),
+ GetDirectives(type),
+ GetDirectSources(sources, type.Name, null));
+ break;
+ }
+ }
+
+ if (!typeNodes.Values.Concat(fieldNodes.Values).Any(static n => !n.Own.Auth.IsEmpty))
+ {
+ return;
+ }
+
+ foreach (var complexType in complexTypes)
+ {
+ foreach (var interfaceType in complexType.Implements)
+ {
+ foreach (var interfaceField in interfaceType.Fields)
+ {
+ if (fieldNodes.TryGetValue((complexType.Name, interfaceField.Name), out var impl))
+ {
+ Link(impl, fieldNodes[(interfaceType.Name, interfaceField.Name)]);
+ }
+ }
+ }
+ }
+
+ foreach (var complexType in complexTypes)
+ {
+ var typeNode = typeNodes[complexType.Name];
+
+ foreach (var interfaceType in complexType.Implements)
+ {
+ typeNode.Closed = typeNode.Closed.And(typeNodes[interfaceType.Name].Own);
+ }
+ }
+
+ foreach (var complexType in complexTypes)
+ {
+ var declaring = typeNodes[complexType.Name].Closed;
+
+ foreach (var field in complexType.Fields)
+ {
+ var node = fieldNodes[(complexType.Name, field.Name)];
+ var returned = typeNodes.TryGetValue(field.Type.NamedType().Name, out var returnNode)
+ ? returnNode.Closed
+ : Tracked.Empty;
+
+ node.Base = node.Own.And(declaring).And(returned);
+ }
+ }
+
+ Close(fieldNodes.Values, static n => n.Base);
+
+ var entries = ImmutableArray.CreateBuilder();
+
+ foreach (var complexType in complexTypes)
+ {
+ var typeNode = typeNodes[complexType.Name];
+
+ Write(typeNode, fusionAuthorization);
+
+ if (typeNode.Own.Auth.IsEmpty && !typeNode.Closed.Auth.IsEmpty)
+ {
+ ReportType(entries, typeNode, complexType, typeNodes);
+ }
+
+ foreach (var field in complexType.Fields)
+ {
+ var fieldNode = fieldNodes[(complexType.Name, field.Name)];
+
+ Write(fieldNode, fusionAuthorization);
+
+ if (fieldNode.Own.Auth.IsEmpty && !fieldNode.Closed.Auth.Matches(fieldNode.Base.Auth))
+ {
+ ReportField(entries, fieldNode);
+ }
+ }
+ }
+
+ if (entries.Count > 0)
+ {
+ mergedSchema.Features.Set(new AuthorizationInheritanceMetadata(entries.ToImmutable()));
+ }
+ }
+
+ private static Node CreateNode(
+ SchemaCoordinate coordinate,
+ DirectiveCollection directives,
+ ImmutableSortedSet directSources)
+ {
+ var directive = directives.FirstOrDefault(DirectiveNames.FusionAuthorization);
+ var auth = directive is null
+ ? MergedAuthorization.Empty
+ : AuthorizationGroups.FromFusionDirective(directive);
+
+ return new Node(coordinate, directives, new Tracked(auth, auth.IsEmpty ? s_noSources : directSources));
+ }
+
+ private static DirectiveCollection GetDirectives(ITypeDefinition type)
+ {
+ return type switch
+ {
+ MutableEnumTypeDefinition e => e.Directives,
+ MutableScalarTypeDefinition s => s.Directives,
+ _ => throw ThrowHelper.UnexpectedTypeKind(type.Kind)
+ };
+ }
+
+ private static void Link(Node left, Node right)
+ {
+ left.Neighbors.Add(right);
+ right.Neighbors.Add(left);
+
+ var leftRoot = left.Find();
+ var rightRoot = right.Find();
+
+ if (!ReferenceEquals(leftRoot, rightRoot))
+ {
+ rightRoot.Parent = leftRoot;
+ }
+ }
+
+ // Every node of a component receives the combined requirement of the whole component.
+ private static void Close(IEnumerable nodes, Func select)
+ {
+ var nodeList = nodes.ToList();
+ var combined = new Dictionary();
+
+ foreach (var node in nodeList)
+ {
+ var root = node.Find();
+ combined[root] = combined.TryGetValue(root, out var current)
+ ? current.And(select(node))
+ : select(node);
+ }
+
+ foreach (var node in nodeList)
+ {
+ node.Closed = combined[node.Find()];
+ }
+ }
+
+ private static void Write(Node node, MutableDirectiveDefinition fusionAuthorization)
+ {
+ if (node.Closed.Auth.Matches(node.Own.Auth))
+ {
+ return;
+ }
+
+ var directive = AuthorizationDirectiveMerger.CreateDirective(
+ fusionAuthorization,
+ node.Closed.Auth);
+ var existing = node.Directives.FirstOrDefault(DirectiveNames.FusionAuthorization);
+
+ if (existing is null)
+ {
+ node.Directives.Insert(0, directive);
+ }
+ else
+ {
+ node.Directives.Replace(existing, directive);
+ }
+ }
+
+ private static void ReportType(
+ ImmutableArray.Builder entries,
+ Node member,
+ MutableComplexTypeDefinition complexType,
+ Dictionary typeNodes)
+ {
+ var paths = new SortedSet(StringComparer.Ordinal);
+ var sourceNames = new SortedSet(StringComparer.Ordinal);
+
+ foreach (var interfaceType in complexType.Implements)
+ {
+ var origin = typeNodes[interfaceType.Name];
+
+ if (origin.Own.Auth.IsEmpty)
+ {
+ continue;
+ }
+
+ paths.Add(FormatPath([origin, member]));
+ sourceNames.UnionWith(origin.Own.Sources);
+ }
+
+ if (paths.Count > 0)
+ {
+ entries.Add(
+ new InheritedAuthorization(member.Coordinate, [.. paths], [.. sourceNames]));
+ }
+ }
+
+ private static void ReportField(
+ ImmutableArray.Builder entries,
+ Node member)
+ {
+ var paths = new SortedSet(StringComparer.Ordinal);
+ var sourceNames = new SortedSet(StringComparer.Ordinal);
+
+ foreach (var origin in GetComponent(member))
+ {
+ var tracked = origin.Base;
+
+ if (ReferenceEquals(origin, member)
+ || tracked.Auth.IsEmpty
+ || member.Base.And(tracked).Auth.Matches(member.Base.Auth))
+ {
+ continue;
+ }
+
+ paths.Add(FormatPath(FindPath(origin, member)));
+ sourceNames.UnionWith(tracked.Sources);
+ }
+
+ if (paths.Count > 0)
+ {
+ entries.Add(
+ new InheritedAuthorization(member.Coordinate, [.. paths], [.. sourceNames]));
+ }
+ }
+
+ // The members of the connected component, in discovery order from the given member.
+ private static List GetComponent(Node start)
+ {
+ var visited = new HashSet { start };
+ var queue = new Queue();
+ var result = new List();
+
+ queue.Enqueue(start);
+
+ while (queue.Count > 0)
+ {
+ var node = queue.Dequeue();
+ result.Add(node);
+
+ foreach (var neighbor in node.Neighbors)
+ {
+ if (visited.Add(neighbor))
+ {
+ queue.Enqueue(neighbor);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private static List FindPath(Node from, Node to)
+ {
+ var previous = new Dictionary { [from] = null };
+ var queue = new Queue();
+ queue.Enqueue(from);
+
+ while (queue.Count > 0)
+ {
+ var node = queue.Dequeue();
+
+ if (ReferenceEquals(node, to))
+ {
+ break;
+ }
+
+ foreach (var neighbor in node.Neighbors)
+ {
+ if (previous.TryAdd(neighbor, node))
+ {
+ queue.Enqueue(neighbor);
+ }
+ }
+ }
+
+ var path = new List();
+
+ for (var current = (Node?)to; current is not null; current = previous[current])
+ {
+ path.Add(current);
+ }
+
+ path.Reverse();
+ return path;
+ }
+
+ private static string FormatPath(List path)
+ => string.Join(" -> ", path.Select(static n => n.Coordinate.ToString()));
+
+ private static ImmutableSortedSet GetDirectSources(
+ MutableSchemaDefinition[] sources,
+ string typeName,
+ string? fieldName)
+ {
+ var names = s_noSources.ToBuilder();
+
+ foreach (var schema in sources)
+ {
+ if (!schema.Types.TryGetType(typeName, out var type)
+ || (fieldName is null
+ && type is MutableObjectTypeDefinition standIn
+ && standIn.Directives.ContainsName(DirectiveNames.InterfaceObject)))
+ {
+ continue;
+ }
+
+ IEnumerable? directives = null;
+
+ if (fieldName is null)
+ {
+ directives = type switch
+ {
+ MutableComplexTypeDefinition c => c.Directives.AsEnumerable(),
+ MutableEnumTypeDefinition e => e.Directives.AsEnumerable(),
+ MutableScalarTypeDefinition s => s.Directives.AsEnumerable(),
+ _ => null
+ };
+ }
+ else if (type is MutableComplexTypeDefinition complexType
+ && complexType.Fields.TryGetField(fieldName, out var field)
+ && field is { IsInternal: false, IsOverridden: false })
+ {
+ directives = field.Directives.AsEnumerable();
+ }
+
+ if (directives?.Any(IsSourceAuthorizationDirective) is true)
+ {
+ names.Add(schema.Name);
+ }
+ }
+
+ return names.ToImmutable();
+ }
+
+ private static bool IsSourceAuthorizationDirective(Directive directive)
+ => directive.Name is DirectiveNames.Authenticated
+ or DirectiveNames.RequiresScopes
+ or DirectiveNames.Policy;
+
+ private readonly record struct Tracked(MergedAuthorization Auth, ImmutableSortedSet Sources)
+ {
+ public static Tracked Empty { get; } = new(MergedAuthorization.Empty, s_noSources);
+
+ public Tracked And(Tracked other)
+ {
+ if (other.Auth.IsEmpty)
+ {
+ return this;
+ }
+
+ if (Auth.IsEmpty)
+ {
+ return other;
+ }
+
+ return new Tracked(Auth.And(other.Auth), Sources.Union(other.Sources));
+ }
+ }
+
+ private sealed class Node(SchemaCoordinate coordinate, DirectiveCollection directives, Tracked own)
+ {
+ public SchemaCoordinate Coordinate { get; } = coordinate;
+
+ public DirectiveCollection Directives { get; } = directives;
+
+ public Tracked Own { get; } = own;
+
+ public Tracked Base { get; set; } = own;
+
+ public Tracked Closed { get; set; } = own;
+
+ public List Neighbors { get; } = [];
+
+ public Node? Parent { get; set; }
+
+ public Node Find()
+ {
+ var root = this;
+
+ while (root.Parent is not null)
+ {
+ root = root.Parent;
+ }
+
+ return root;
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ApplyPolicyMutableEnumTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ApplyPolicyMutableEnumTypeDefinition.cs
deleted file mode 100644
index a08cdb02170..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ApplyPolicyMutableEnumTypeDefinition.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using HotChocolate.Types.Mutable;
-
-namespace HotChocolate.Fusion.Definitions;
-
-internal sealed class ApplyPolicyMutableEnumTypeDefinition : MutableEnumTypeDefinition
-{
- public ApplyPolicyMutableEnumTypeDefinition()
- : base(WellKnownTypeNames.ApplyPolicy)
- {
- Values.Add(new MutableEnumValue("BEFORE_RESOLVER"));
- Values.Add(new MutableEnumValue("AFTER_RESOLVER"));
- Values.Add(new MutableEnumValue("VALIDATION"));
- }
-
- public static ApplyPolicyMutableEnumTypeDefinition Create()
- {
- return new ApplyPolicyMutableEnumTypeDefinition();
- }
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthenticatedMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthenticatedMutableDirectiveDefinition.cs
new file mode 100644
index 00000000000..5d42aaf2591
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthenticatedMutableDirectiveDefinition.cs
@@ -0,0 +1,24 @@
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using static HotChocolate.Fusion.Properties.CompositionResources;
+
+namespace HotChocolate.Fusion.Definitions;
+
+///
+/// The @authenticated directive marks a type system member as requiring an authenticated
+/// client.
+///
+internal sealed class AuthenticatedMutableDirectiveDefinition : MutableDirectiveDefinition
+{
+ public AuthenticatedMutableDirectiveDefinition() : base(WellKnownDirectiveNames.Authenticated)
+ {
+ Description = AuthenticatedMutableDirectiveDefinition_Description;
+
+ Locations =
+ DirectiveLocation.Enum
+ | DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Object
+ | DirectiveLocation.Scalar;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthorizeMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthorizeMutableDirectiveDefinition.cs
deleted file mode 100644
index b27b1494e14..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthorizeMutableDirectiveDefinition.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using HotChocolate.Language;
-using HotChocolate.Types;
-using HotChocolate.Types.Mutable;
-using DirectiveLocation = HotChocolate.Types.DirectiveLocation;
-
-namespace HotChocolate.Fusion.Definitions;
-
-internal sealed class AuthorizeMutableDirectiveDefinition : MutableDirectiveDefinition
-{
- public AuthorizeMutableDirectiveDefinition(
- MutableScalarTypeDefinition stringType,
- MutableEnumTypeDefinition applyPolicyType)
- : base(WellKnownDirectiveNames.Authorize)
- {
- Arguments.Add(new MutableInputFieldDefinition(WellKnownArgumentNames.Policy, stringType));
- Arguments.Add(
- new MutableInputFieldDefinition(
- WellKnownArgumentNames.Roles,
- new ListType(new NonNullType(stringType))));
- Arguments.Add(
- new MutableInputFieldDefinition(WellKnownArgumentNames.Apply, new NonNullType(applyPolicyType))
- {
- DefaultValue = new EnumValueNode("BEFORE_RESOLVER")
- });
- IsRepeatable = true;
- Locations = DirectiveLocation.FieldDefinition | DirectiveLocation.Object;
- }
-
- public static AuthorizeMutableDirectiveDefinition Create(ISchemaDefinition schema)
- {
- if (!schema.Types.TryGetType(
- SpecScalarNames.String.Name,
- out var stringType))
- {
- stringType = BuiltIns.String.Create();
- }
-
- if (!schema.Types.TryGetType(
- WellKnownTypeNames.ApplyPolicy,
- out var applyPolicyType))
- {
- applyPolicyType = ApplyPolicyMutableEnumTypeDefinition.Create();
- }
-
- return new AuthorizeMutableDirectiveDefinition(stringType, applyPolicyType);
- }
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionAuthorizationMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionAuthorizationMutableDirectiveDefinition.cs
new file mode 100644
index 00000000000..5af582b2931
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/FusionAuthorizationMutableDirectiveDefinition.cs
@@ -0,0 +1,54 @@
+using HotChocolate.Language;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using static HotChocolate.Fusion.Properties.CompositionResources;
+using static HotChocolate.Fusion.WellKnownDirectiveNames;
+using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames;
+using DirectiveLocation = HotChocolate.Types.DirectiveLocation;
+
+namespace HotChocolate.Fusion.Definitions;
+
+///
+/// The @fusion__authorization directive carries the merged authorization requirement of a
+/// type system member.
+///
+internal sealed class FusionAuthorizationMutableDirectiveDefinition : MutableDirectiveDefinition
+{
+ public FusionAuthorizationMutableDirectiveDefinition(
+ MutableScalarTypeDefinition stringType,
+ MutableScalarTypeDefinition booleanType)
+ : base(FusionAuthorization)
+ {
+ Description = FusionAuthorizationMutableDirectiveDefinition_Description;
+
+ Arguments.Add(
+ new MutableInputFieldDefinition(ArgumentNames.Authenticated, new NonNullType(booleanType))
+ {
+ DefaultValue = BooleanValueNode.False,
+ Description = FusionAuthorizationMutableDirectiveDefinition_Argument_Authenticated_Description
+ });
+
+ Arguments.Add(
+ new MutableInputFieldDefinition(
+ ArgumentNames.Scopes,
+ new ListType(new NonNullType(new ListType(new NonNullType(stringType)))))
+ {
+ Description = FusionAuthorizationMutableDirectiveDefinition_Argument_Scopes_Description
+ });
+
+ Arguments.Add(
+ new MutableInputFieldDefinition(
+ ArgumentNames.Policies,
+ new ListType(new NonNullType(new ListType(new NonNullType(stringType)))))
+ {
+ Description = FusionAuthorizationMutableDirectiveDefinition_Argument_Policies_Description
+ });
+
+ Locations =
+ DirectiveLocation.Enum
+ | DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Object
+ | DirectiveLocation.Scalar;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PolicyMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PolicyMutableDirectiveDefinition.cs
new file mode 100644
index 00000000000..efbf2f938fe
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/PolicyMutableDirectiveDefinition.cs
@@ -0,0 +1,33 @@
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using static HotChocolate.Fusion.Properties.CompositionResources;
+
+namespace HotChocolate.Fusion.Definitions;
+
+///
+/// The @policy directive requires the client to satisfy all policies of at least one of
+/// the given policy groups.
+///
+internal sealed class PolicyMutableDirectiveDefinition : MutableDirectiveDefinition
+{
+ public PolicyMutableDirectiveDefinition(MutableScalarTypeDefinition stringType)
+ : base(WellKnownDirectiveNames.Policy)
+ {
+ Description = PolicyMutableDirectiveDefinition_Description;
+
+ Arguments.Add(
+ new MutableInputFieldDefinition(
+ WellKnownArgumentNames.Policies,
+ new NonNullType(new ListType(new NonNullType(new ListType(new NonNullType(stringType))))))
+ {
+ Description = PolicyMutableDirectiveDefinition_Argument_Policies_Description
+ });
+
+ Locations =
+ DirectiveLocation.Enum
+ | DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Object
+ | DirectiveLocation.Scalar;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/RequiresScopesMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/RequiresScopesMutableDirectiveDefinition.cs
new file mode 100644
index 00000000000..56d8aad9508
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/RequiresScopesMutableDirectiveDefinition.cs
@@ -0,0 +1,33 @@
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using static HotChocolate.Fusion.Properties.CompositionResources;
+
+namespace HotChocolate.Fusion.Definitions;
+
+///
+/// The @requiresScopes directive requires the client to be granted all scopes of at least
+/// one of the given scope groups.
+///
+internal sealed class RequiresScopesMutableDirectiveDefinition : MutableDirectiveDefinition
+{
+ public RequiresScopesMutableDirectiveDefinition(MutableScalarTypeDefinition stringType)
+ : base(WellKnownDirectiveNames.RequiresScopes)
+ {
+ Description = RequiresScopesMutableDirectiveDefinition_Description;
+
+ Arguments.Add(
+ new MutableInputFieldDefinition(
+ WellKnownArgumentNames.Scopes,
+ new NonNullType(new ListType(new NonNullType(new ListType(new NonNullType(stringType))))))
+ {
+ Description = RequiresScopesMutableDirectiveDefinition_Argument_Scopes_Description
+ });
+
+ Locations =
+ DirectiveLocation.Enum
+ | DirectiveLocation.FieldDefinition
+ | DirectiveLocation.Interface
+ | DirectiveLocation.Object
+ | DirectiveLocation.Scalar;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizationDirectiveMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizationDirectiveMerger.cs
new file mode 100644
index 00000000000..ecc9bded08e
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizationDirectiveMerger.cs
@@ -0,0 +1,84 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Directives;
+using HotChocolate.Fusion.Extensions;
+using HotChocolate.Fusion.Info;
+using HotChocolate.Fusion.Options;
+using HotChocolate.Language;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames;
+using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames;
+
+namespace HotChocolate.Fusion.DirectiveMergers;
+
+internal sealed class AuthorizationDirectiveMerger(MutableDirectiveDefinition fusionAuthorization)
+ : DirectiveMergerBase(DirectiveMergeBehavior.Ignore)
+{
+ public override string DirectiveName => DirectiveNames.FusionAuthorization;
+
+ public override MutableDirectiveDefinition GetCanonicalDirectiveDefinition(MutableSchemaDefinition schema)
+ {
+ return fusionAuthorization;
+ }
+
+ public override void MergeDirectives(
+ IDirectivesProvider mergedMember,
+ ImmutableArray memberDefinitions,
+ MutableSchemaDefinition mergedSchema)
+ {
+ var merged = AuthorizationGroups.Merge(memberDefinitions);
+
+ if (merged.IsEmpty)
+ {
+ return;
+ }
+
+ mergedMember.AddDirective(CreateDirective(fusionAuthorization, merged));
+ }
+
+ ///
+ /// Creates the @fusion__authorization directive that carries .
+ ///
+ public static Directive CreateDirective(
+ MutableDirectiveDefinition fusionAuthorization,
+ MergedAuthorization merged)
+ {
+ var arguments = new List();
+
+ if (merged.Authenticated)
+ {
+ arguments.Add(new ArgumentAssignment(ArgumentNames.Authenticated, true));
+ }
+
+ if (!merged.Scopes.IsEmpty)
+ {
+ arguments.Add(new ArgumentAssignment(ArgumentNames.Scopes, ToValueNode(merged.Scopes)));
+ }
+
+ if (!merged.Policies.IsEmpty)
+ {
+ arguments.Add(new ArgumentAssignment(ArgumentNames.Policies, ToValueNode(merged.Policies)));
+ }
+
+ return new Directive(fusionAuthorization, arguments);
+ }
+
+ private static ListValueNode ToValueNode(ImmutableArray> groups)
+ {
+ var items = new List(groups.Length);
+
+ foreach (var group in groups)
+ {
+ var values = new List(group.Length);
+
+ foreach (var value in group)
+ {
+ values.Add(new StringValueNode(value));
+ }
+
+ items.Add(new ListValueNode(values));
+ }
+
+ return new ListValueNode(items);
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizeDirectiveMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizeDirectiveMerger.cs
deleted file mode 100644
index 022de49f258..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizeDirectiveMerger.cs
+++ /dev/null
@@ -1,136 +0,0 @@
-using System.Collections.Immutable;
-using HotChocolate.Fusion.Definitions;
-using HotChocolate.Fusion.Directives;
-using HotChocolate.Fusion.Extensions;
-using HotChocolate.Fusion.Info;
-using HotChocolate.Fusion.Options;
-using HotChocolate.Language;
-using HotChocolate.Types;
-using HotChocolate.Types.Mutable;
-using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames;
-using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames;
-
-namespace HotChocolate.Fusion.DirectiveMergers;
-
-internal class AuthorizeDirectiveMerger(DirectiveMergeBehavior mergeBehavior)
- : DirectiveMergerBase(mergeBehavior)
-{
- public override string DirectiveName => DirectiveNames.Authorize;
-
- public override MutableDirectiveDefinition GetCanonicalDirectiveDefinition(MutableSchemaDefinition schema)
- {
- return AuthorizeMutableDirectiveDefinition.Create(schema);
- }
-
- public override void MergeDirectives(
- IDirectivesProvider mergedMember,
- ImmutableArray memberDefinitions,
- MutableSchemaDefinition mergedSchema)
- {
- if (!mergedSchema.DirectiveDefinitions.TryGetDirective(DirectiveName, out var directiveDefinition))
- {
- // Merged definition not found.
- return;
- }
-
- var uniqueAuthorizeDirectives =
- memberDefinitions
- .SelectMany(d => d.Member.Directives.Where(dir => dir.Name == DirectiveNames.Authorize))
- .Select(AuthorizeDirective.From)
- .Distinct(AuthorizeDirectiveEqualityComparer.Instance);
-
- foreach (var authorizeDirective in uniqueAuthorizeDirectives)
- {
- var arguments = new List();
-
- if (authorizeDirective.Policy is not null)
- {
- arguments.Add(
- new ArgumentAssignment(ArgumentNames.Policy, authorizeDirective.Policy));
- }
-
- if (authorizeDirective.Roles is not null)
- {
- arguments.Add(
- new ArgumentAssignment(
- ArgumentNames.Roles,
- new ListValueNode(
- authorizeDirective.Roles
- .Order(StringComparer.Ordinal)
- .Select(r => new StringValueNode(r))
- .ToList())));
- }
-
- if (authorizeDirective.Apply is { } apply and not ApplyPolicy.BeforeResolver)
- {
- arguments.Add(
- new ArgumentAssignment(
- ArgumentNames.Apply,
- new EnumValueNode(
- apply == ApplyPolicy.AfterResolver ? "AFTER_RESOLVER" : "VALIDATION")));
- }
-
- mergedMember.AddDirective(new Directive(directiveDefinition, arguments));
- }
- }
-
- private sealed class AuthorizeDirectiveEqualityComparer
- : IEqualityComparer
- {
- public static readonly AuthorizeDirectiveEqualityComparer Instance = new();
-
- public bool Equals(AuthorizeDirective? x, AuthorizeDirective? y)
- {
- if (ReferenceEquals(x, y))
- {
- return true;
- }
-
- if (x is null || y is null)
- {
- return false;
- }
-
- return x.Policy == y.Policy
- && (x.Apply ?? ApplyPolicy.BeforeResolver) == (y.Apply ?? ApplyPolicy.BeforeResolver)
- && RolesEqual(x.Roles, y.Roles);
- }
-
- public int GetHashCode(AuthorizeDirective obj)
- {
- var hash = new HashCode();
- hash.Add(obj.Policy);
- hash.Add(obj.Apply ?? ApplyPolicy.BeforeResolver);
-
- if (obj.Roles is not null)
- {
- foreach (var role in obj.Roles.Order(StringComparer.Ordinal))
- {
- hash.Add(role);
- }
- }
-
- return hash.ToHashCode();
- }
-
- private static bool RolesEqual(List? a, List? b)
- {
- if (a is null && b is null)
- {
- return true;
- }
-
- if (a is null || b is null)
- {
- return false;
- }
-
- if (a.Count != b.Count)
- {
- return false;
- }
-
- return a.Order(StringComparer.Ordinal).SequenceEqual(b.Order(StringComparer.Ordinal));
- }
- }
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/ApplyPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/ApplyPolicy.cs
deleted file mode 100644
index 5c5aeab1527..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/ApplyPolicy.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace HotChocolate.Fusion.Directives;
-
-internal enum ApplyPolicy
-{
- BeforeResolver = 0,
- AfterResolver = 1,
- Validation = 2
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizationGroups.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizationGroups.cs
new file mode 100644
index 00000000000..c3ea2edef36
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizationGroups.cs
@@ -0,0 +1,288 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Info;
+using HotChocolate.Fusion.Options;
+using HotChocolate.Language;
+using HotChocolate.Types;
+using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames;
+using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames;
+
+namespace HotChocolate.Fusion.Directives;
+
+internal static class AuthorizationGroups
+{
+ public static MergedAuthorization Merge(IEnumerable memberDefinitions)
+ {
+ var authenticated = false;
+ var scopeFactors = new List>>();
+ var policyFactors = new List>>();
+
+ foreach (var (member, _) in memberDefinitions)
+ {
+ foreach (var directive in member.Directives)
+ {
+ switch (directive.Name)
+ {
+ case DirectiveNames.Authenticated:
+ authenticated = true;
+ break;
+
+ case DirectiveNames.RequiresScopes:
+ scopeFactors.Add(
+ ParseGroups(directive, ArgumentNames.Scopes));
+ break;
+
+ case DirectiveNames.Policy:
+ policyFactors.Add(
+ ParseGroups(directive, ArgumentNames.Policies));
+ break;
+ }
+ }
+ }
+
+ return new MergedAuthorization(authenticated, Reduce(scopeFactors), Reduce(policyFactors));
+ }
+
+ ///
+ /// Reads the requirement carried by a @fusion__authorization directive.
+ ///
+ public static MergedAuthorization FromFusionDirective(IDirective directive)
+ {
+ var authenticated = directive.Arguments.TryGetValue(ArgumentNames.Authenticated, out var value)
+ && value is BooleanValueNode { Value: true };
+
+ return new MergedAuthorization(
+ authenticated,
+ ReadGroups(directive, ArgumentNames.Scopes),
+ ReadGroups(directive, ArgumentNames.Policies));
+ }
+
+ private static ImmutableArray> ReadGroups(
+ IDirective directive,
+ string argumentName)
+ {
+ return directive.Arguments.ContainsName(argumentName)
+ ? Reduce([ParseGroups(directive, argumentName)])
+ : [];
+ }
+
+ ///
+ /// Combines the alternative groups of every factor into the canonical reduced form: sorted,
+ /// deduplicated, without supersets, ordered by length and then lexicographically.
+ /// Every factor must contain at least one non-empty group.
+ ///
+ public static ImmutableArray> Reduce(
+ IEnumerable>> factors)
+ {
+ var result = ImmutableArray>.Empty;
+ var hasFactor = false;
+
+ foreach (var factor in factors)
+ {
+ var groups = factor.Select(Normalize).ToList();
+
+ if (!hasFactor)
+ {
+ result = Minimize(groups);
+ hasFactor = true;
+ continue;
+ }
+
+ var combined = new List>(result.Length * groups.Count);
+
+ foreach (var left in result)
+ {
+ foreach (var right in groups)
+ {
+ combined.Add(Normalize(left.AddRange(right)));
+ }
+ }
+
+ result = Minimize(combined);
+ }
+
+ return result;
+ }
+
+ private static ImmutableArray> ParseGroups(
+ IDirective directive,
+ string argumentName)
+ {
+ if (!directive.Arguments.TryGetValue(argumentName, out var argument))
+ {
+ throw ThrowHelper.AuthorizationDirectiveArgumentInvalid(directive.Name, argumentName);
+ }
+
+ // A single string coerces to a list of one group of one.
+ if (argument is StringValueNode singleValue)
+ {
+ return [[singleValue.Value]];
+ }
+
+ if (argument is not ListValueNode outer)
+ {
+ throw ThrowHelper.AuthorizationDirectiveArgumentInvalid(directive.Name, argumentName);
+ }
+
+ var groups = ImmutableArray.CreateBuilder>(outer.Items.Count);
+
+ foreach (var item in outer.Items)
+ {
+ switch (item)
+ {
+ // A single string coerces to a group of one.
+ case StringValueNode single:
+ groups.Add([single.Value]);
+ break;
+
+ case ListValueNode inner:
+ var values = ImmutableArray.CreateBuilder(inner.Items.Count);
+
+ foreach (var value in inner.Items)
+ {
+ if (value is not StringValueNode stringValue)
+ {
+ throw ThrowHelper.AuthorizationDirectiveArgumentInvalid(
+ directive.Name,
+ argumentName);
+ }
+
+ values.Add(stringValue.Value);
+ }
+
+ groups.Add(values.ToImmutable());
+ break;
+
+ default:
+ throw ThrowHelper.AuthorizationDirectiveArgumentInvalid(
+ directive.Name,
+ argumentName);
+ }
+ }
+
+ return groups.ToImmutable();
+ }
+
+ private static ImmutableArray Normalize(ImmutableArray group)
+ {
+ var set = new SortedSet(group, StringComparer.Ordinal);
+ return [.. set];
+ }
+
+ private static ImmutableArray> Minimize(
+ List> groups)
+ {
+ groups.Sort(CompareGroups);
+
+ var kept = new List>(groups.Count);
+
+ foreach (var candidate in groups)
+ {
+ var redundant = false;
+
+ foreach (var existing in kept)
+ {
+ if (IsSubset(existing, candidate))
+ {
+ redundant = true;
+ break;
+ }
+ }
+
+ if (!redundant)
+ {
+ kept.Add(candidate);
+ }
+ }
+
+ return [.. kept];
+ }
+
+ ///
+ /// Determines whether every alternative group of contains an
+ /// alternative group of . An empty dependency is always covered.
+ ///
+ public static bool Covers(
+ ImmutableArray> requirement,
+ ImmutableArray> dependency)
+ {
+ if (dependency.IsEmpty)
+ {
+ return true;
+ }
+
+ if (requirement.IsEmpty)
+ {
+ return false;
+ }
+
+ foreach (var group in requirement)
+ {
+ var covered = false;
+
+ foreach (var dependencyGroup in dependency)
+ {
+ if (IsSubset(dependencyGroup, group))
+ {
+ covered = true;
+ break;
+ }
+ }
+
+ if (!covered)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // Both groups are sorted and deduplicated.
+ private static bool IsSubset(ImmutableArray subset, ImmutableArray superset)
+ {
+ if (subset.Length > superset.Length)
+ {
+ return false;
+ }
+
+ var j = 0;
+
+ foreach (var value in subset)
+ {
+ while (j < superset.Length
+ && string.CompareOrdinal(superset[j], value) < 0)
+ {
+ j++;
+ }
+
+ if (j == superset.Length || !string.Equals(superset[j], value, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ j++;
+ }
+
+ return true;
+ }
+
+ private static int CompareGroups(ImmutableArray x, ImmutableArray y)
+ {
+ if (x.Length != y.Length)
+ {
+ return x.Length.CompareTo(y.Length);
+ }
+
+ for (var i = 0; i < x.Length; i++)
+ {
+ var result = string.CompareOrdinal(x[i], y[i]);
+
+ if (result != 0)
+ {
+ return result;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizeDirective.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizeDirective.cs
deleted file mode 100644
index bfbd37d79de..00000000000
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizeDirective.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using HotChocolate.Language;
-using HotChocolate.Types;
-using static HotChocolate.Fusion.Properties.CompositionResources;
-using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames;
-
-namespace HotChocolate.Fusion.Directives;
-
-internal sealed class AuthorizeDirective(
- string? policy,
- List? roles,
- ApplyPolicy? apply = null)
-{
- public string? Policy { get; } = policy;
-
- public List? Roles { get; } = roles;
-
- public ApplyPolicy? Apply { get; } = apply;
-
- public static AuthorizeDirective From(IDirective directive)
- {
- string? policy = null;
- List? roles = null;
- ApplyPolicy? applyPolicy = null;
-
- if (directive.Arguments.TryGetValue(ArgumentNames.Policy, out var policyArg))
- {
- policy = policyArg switch
- {
- StringValueNode stringValueNode => stringValueNode.Value,
- NullValueNode => null,
- _ => throw new InvalidOperationException(AuthorizeDirective_PolicyArgument_Invalid)
- };
- }
-
- if (directive.Arguments.TryGetValue(ArgumentNames.Roles, out var rolesArg))
- {
- roles = rolesArg switch
- {
- ListValueNode listValueNode when listValueNode.Items.All(v => v is StringValueNode)
- => listValueNode.Items.Cast().Select(v => v.Value).ToList(),
- NullValueNode => null,
- _ => throw new InvalidOperationException(AuthorizeDirective_RolesArgument_Invalid)
- };
- }
-
- if (directive.Arguments.TryGetValue(ArgumentNames.Apply, out var applyArg))
- {
- applyPolicy = applyArg switch
- {
- EnumValueNode enumValueNode => GetApplyPolicy(enumValueNode.Value),
- _ => throw new InvalidOperationException(AuthorizeDirective_ApplyArgument_Invalid)
- };
- }
-
- return new AuthorizeDirective(policy, roles, applyPolicy);
- }
-
- private static ApplyPolicy GetApplyPolicy(string applyPolicyValue)
- {
- return applyPolicyValue switch
- {
- "BEFORE_RESOLVER" => ApplyPolicy.BeforeResolver,
- "AFTER_RESOLVER" => ApplyPolicy.AfterResolver,
- "VALIDATION" => ApplyPolicy.Validation,
- _ => throw new InvalidOperationException(
- string.Format(AuthorizeDirective_ApplyArgument_InvalidEnumValue, applyPolicyValue))
- };
- }
-}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/MergedAuthorization.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/MergedAuthorization.cs
new file mode 100644
index 00000000000..70d69c94bb5
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/MergedAuthorization.cs
@@ -0,0 +1,117 @@
+using System.Collections.Immutable;
+using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames;
+
+namespace HotChocolate.Fusion.Directives;
+
+///
+/// The merged authorization requirement of a type system member across source schemas.
+///
+///
+/// true if any source schema marks the member with @authenticated.
+///
+///
+/// The reduced scope groups in canonical order. A group is a set of scopes that must all be
+/// present, and one satisfied group is sufficient. Empty if no source schema requires scopes.
+///
+///
+/// The reduced policy groups in canonical order, with the same semantics as the scope groups.
+///
+internal sealed record MergedAuthorization(
+ bool Authenticated,
+ ImmutableArray> Scopes,
+ ImmutableArray> Policies)
+{
+ public static MergedAuthorization Empty { get; } = new(false, [], []);
+
+ public bool IsEmpty => !Authenticated && Scopes.IsEmpty && Policies.IsEmpty;
+
+ ///
+ /// Combines this requirement with so that both must be satisfied.
+ ///
+ public MergedAuthorization And(MergedAuthorization other)
+ {
+ if (other.IsEmpty)
+ {
+ return this;
+ }
+
+ if (IsEmpty)
+ {
+ return other;
+ }
+
+ return new MergedAuthorization(
+ Authenticated || other.Authenticated,
+ CombineGroups(Scopes, other.Scopes),
+ CombineGroups(Policies, other.Policies));
+ }
+
+ private static ImmutableArray> CombineGroups(
+ ImmutableArray> left,
+ ImmutableArray> right)
+ {
+ if (left.IsEmpty)
+ {
+ return right;
+ }
+
+ return right.IsEmpty ? left : AuthorizationGroups.Reduce([left, right]);
+ }
+
+ ///
+ /// Determines whether carries the same requirement. Both
+ /// requirements are expected in canonical form.
+ ///
+ public bool Matches(MergedAuthorization other)
+ {
+ return Authenticated == other.Authenticated
+ && GroupsMatch(Scopes, other.Scopes)
+ && GroupsMatch(Policies, other.Policies);
+ }
+
+ ///
+ /// Gets the names of the directives of that this requirement
+ /// does not cover. Both requirements are expected in canonical form.
+ ///
+ public ImmutableArray GetUncovered(MergedAuthorization dependency)
+ {
+ var uncovered = ImmutableArray.CreateBuilder();
+
+ if (dependency.Authenticated && !Authenticated)
+ {
+ uncovered.Add(DirectiveNames.Authenticated);
+ }
+
+ if (!AuthorizationGroups.Covers(Scopes, dependency.Scopes))
+ {
+ uncovered.Add(DirectiveNames.RequiresScopes);
+ }
+
+ if (!AuthorizationGroups.Covers(Policies, dependency.Policies))
+ {
+ uncovered.Add(DirectiveNames.Policy);
+ }
+
+ return uncovered.ToImmutable();
+ }
+
+ private static bool GroupsMatch(
+ ImmutableArray> left,
+ ImmutableArray> right)
+ {
+ if (left.Length != right.Length)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Length; i++)
+ {
+ if (!left[i].AsSpan().SequenceEqual(right[i].AsSpan()))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/AuthorizationInheritanceMetadata.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/AuthorizationInheritanceMetadata.cs
new file mode 100644
index 00000000000..33f5edd4cde
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/AuthorizationInheritanceMetadata.cs
@@ -0,0 +1,13 @@
+using System.Collections.Immutable;
+
+namespace HotChocolate.Fusion.Features;
+
+///
+/// The members of the merged schema that newly require authorization because of interface
+/// inheritance, although none of their source schemas annotated them directly.
+///
+internal sealed class AuthorizationInheritanceMetadata(
+ ImmutableArray entries)
+{
+ public ImmutableArray Entries { get; } = entries;
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Features/InheritedAuthorization.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/InheritedAuthorization.cs
new file mode 100644
index 00000000000..c1b81289b69
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Features/InheritedAuthorization.cs
@@ -0,0 +1,16 @@
+using System.Collections.Immutable;
+
+namespace HotChocolate.Fusion.Features;
+
+///
+/// A merged member that is protected by inheritance.
+///
+/// The protected member.
+///
+/// The paths from the members that contribute the requirement to the protected member.
+///
+/// The source schemas that annotated the contributing members.
+internal sealed record InheritedAuthorization(
+ SchemaCoordinate Coordinate,
+ ImmutableArray Paths,
+ ImmutableArray SourceSchemas);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs
index 3ce0ed3a680..6c725924e8b 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/FusionBuiltIns.cs
@@ -18,6 +18,7 @@ internal static class FusionBuiltIns
public static FrozenDictionary SourceSchemaDirectives { get; } =
new HashSet(
[
+ new AuthenticatedMutableDirectiveDefinition(),
new EventCursorMutableDirectiveDefinition(),
new ExternalMutableDirectiveDefinition(),
new ImplementMutableDirectiveDefinition(),
@@ -28,8 +29,10 @@ internal static class FusionBuiltIns
new KeyMutableDirectiveDefinition(s_fieldSelectionSetType),
new LookupMutableDirectiveDefinition(),
new OverrideMutableDirectiveDefinition(s_stringType),
+ new PolicyMutableDirectiveDefinition(s_stringType),
new ProvidesMutableDirectiveDefinition(s_fieldSelectionSetType),
new RequireMutableDirectiveDefinition(s_fieldSelectionMapType),
+ new RequiresScopesMutableDirectiveDefinition(s_stringType),
new ShareableMutableDirectiveDefinition(),
new EventStreamMutableDirectiveDefinition(s_fieldSelectionSetType, s_stringType)
]).ToFrozenDictionary(d => d.Name);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs
index 0ad908b9167..6b8d709f3f5 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs
@@ -2,6 +2,14 @@ namespace HotChocolate.Fusion.Logging;
public static class LogEntryCodes
{
+ public const string AuthenticatedMismatch = "AUTHENTICATED_MISMATCH";
+ public const string AuthorizationDirectiveArgumentInvalid =
+ "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT";
+ public const string AuthorizationGroupCountExceeded = "AUTHORIZATION_GROUP_COUNT_EXCEEDED";
+ public const string AuthorizationInherited = "AUTHORIZATION_INHERITED";
+ public const string AuthorizationOnInterfaceObject = "AUTHORIZATION_ON_INTERFACE_OBJECT";
+ public const string AuthorizationTransitiveRequirementsMissing =
+ "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING";
public const string ConflictingApolloFederationVersion =
"CONFLICTING_APOLLO_FEDERATION_VERSION";
public const string ConflictingSourceSchemaName = "CONFLICTING_SOURCE_SCHEMA_NAME";
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs
index 37e41e5eb30..ad1d57dd28d 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryHelper.cs
@@ -8,6 +8,128 @@ namespace HotChocolate.Fusion.Logging;
internal static class LogEntryHelper
{
+ public static LogEntry AuthenticatedMismatch(
+ SchemaCoordinate coordinate,
+ MutableSchemaDefinition schema,
+ IEnumerable markedSchemaNames,
+ IEnumerable unmarkedSchemaNames)
+ {
+ return LogEntryBuilder.New()
+ .SetMessage(
+ LogEntryHelper_AuthenticatedMismatch,
+ coordinate,
+ FormatSchemaNames(markedSchemaNames),
+ FormatSchemaNames(unmarkedSchemaNames))
+ .SetCode(LogEntryCodes.AuthenticatedMismatch)
+ .SetSeverity(LogSeverity.Warning)
+ .SetCoordinate(coordinate)
+ .SetSchema(schema)
+ .Build();
+ }
+
+ public static LogEntry AuthorizationDirectiveArgumentInvalid(
+ string directiveName,
+ string argumentName,
+ IValueNode? value,
+ SchemaCoordinate coordinate,
+ MutableSchemaDefinition schema)
+ {
+ return LogEntryBuilder.New()
+ .SetMessage(
+ LogEntryHelper_AuthorizationDirectiveArgumentInvalid,
+ directiveName,
+ argumentName,
+ coordinate,
+ schema.Name,
+ value?.ToString() ?? "undefined")
+ .SetCode(LogEntryCodes.AuthorizationDirectiveArgumentInvalid)
+ .SetSeverity(LogSeverity.Error)
+ .SetCoordinate(coordinate)
+ .SetSchema(schema)
+ .Build();
+ }
+
+ public static LogEntry AuthorizationGroupCountExceeded(
+ string requirement,
+ SchemaCoordinate coordinate,
+ MutableSchemaDefinition schema,
+ int groupCount,
+ int threshold)
+ {
+ return LogEntryBuilder.New()
+ .SetMessage(
+ LogEntryHelper_AuthorizationGroupCountExceeded,
+ requirement,
+ coordinate,
+ groupCount,
+ threshold)
+ .SetCode(LogEntryCodes.AuthorizationGroupCountExceeded)
+ .SetSeverity(LogSeverity.Warning)
+ .SetCoordinate(coordinate)
+ .SetSchema(schema)
+ .Build();
+ }
+
+ public static LogEntry AuthorizationInherited(
+ SchemaCoordinate coordinate,
+ ImmutableArray paths,
+ ImmutableArray schemaNames)
+ {
+ return LogEntryBuilder.New()
+ .SetMessage(
+ LogEntryHelper_AuthorizationInherited,
+ coordinate,
+ string.Join("; ", paths),
+ FormatSchemaNames(schemaNames))
+ .SetCode(LogEntryCodes.AuthorizationInherited)
+ .SetSeverity(LogSeverity.Warning)
+ .SetCoordinate(coordinate)
+ .Build();
+ }
+
+ public static LogEntry AuthorizationOnInterfaceObject(
+ MutableObjectTypeDefinition standIn,
+ string directiveName,
+ MutableSchemaDefinition schema)
+ {
+ return LogEntryBuilder.New()
+ .SetMessage(
+ LogEntryHelper_AuthorizationOnInterfaceObject,
+ standIn.Name,
+ schema.Name,
+ directiveName)
+ .SetCode(LogEntryCodes.AuthorizationOnInterfaceObject)
+ .SetSeverity(LogSeverity.Error)
+ .SetTypeSystemMember(standIn)
+ .SetSchema(schema)
+ .Build();
+ }
+
+ public static LogEntry AuthorizationTransitiveRequirementsMissing(
+ SchemaCoordinate requiringCoordinate,
+ SchemaCoordinate dependencyCoordinate,
+ SchemaCoordinate viaCoordinate,
+ MutableSchemaDefinition schema,
+ IEnumerable uncoveredDirectiveNames)
+ {
+ return LogEntryBuilder.New()
+ .SetMessage(
+ LogEntryHelper_AuthorizationTransitiveRequirementsMissing,
+ requiringCoordinate,
+ dependencyCoordinate,
+ viaCoordinate,
+ schema.Name,
+ string.Join(", ", uncoveredDirectiveNames.Select(static n => $"@{n}")))
+ .SetCode(LogEntryCodes.AuthorizationTransitiveRequirementsMissing)
+ .SetSeverity(LogSeverity.Error)
+ .SetCoordinate(requiringCoordinate)
+ .SetSchema(schema)
+ .Build();
+ }
+
+ private static string FormatSchemaNames(IEnumerable schemaNames)
+ => string.Join(", ", schemaNames.Select(static n => $"'{n}'"));
+
public static LogEntry DisallowedInaccessibleBuiltInScalar(
MutableScalarTypeDefinition scalar,
MutableSchemaDefinition schema)
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ThrowHelper.cs
index 8f2efd09777..75d577f8abe 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ThrowHelper.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/ThrowHelper.cs
@@ -1,3 +1,6 @@
+using HotChocolate.Types;
+using static HotChocolate.Fusion.Properties.CompositionResources;
+
namespace HotChocolate.Fusion.Options;
internal static class ThrowHelper
@@ -10,4 +13,12 @@ public static ArgumentOutOfRangeException InvalidDefaultListSize(int value)
public static ArgumentOutOfRangeException UnexpectedCostCoordinateKind(CostCoordinateKind kind)
=> new(nameof(kind));
+
+ public static InvalidOperationException AuthorizationDirectiveArgumentInvalid(
+ string directiveName,
+ string argumentName)
+ => new(string.Format(AuthorizationDirective_Argument_Invalid, directiveName, argumentName));
+
+ public static InvalidOperationException UnexpectedTypeKind(TypeKind kind)
+ => new(string.Format(AuthorizationInheritance_UnexpectedTypeKind, kind));
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/AuthorizationInheritedRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/AuthorizationInheritedRule.cs
new file mode 100644
index 00000000000..e862a9d090a
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/AuthorizationInheritedRule.cs
@@ -0,0 +1,27 @@
+using HotChocolate.Fusion.Events;
+using HotChocolate.Fusion.Events.Contracts;
+using HotChocolate.Fusion.Features;
+using static HotChocolate.Fusion.Logging.LogEntryHelper;
+
+namespace HotChocolate.Fusion.PostMergeValidationRules;
+
+///
+/// Warns about every merged member that requires authorization through interface inheritance
+/// although none of its source schemas annotated it directly.
+///
+internal sealed class AuthorizationInheritedRule : IEventHandler
+{
+ public void Handle(SchemaEvent @event, CompositionContext context)
+ {
+ if (@event.Schema.Features.Get() is not { } metadata)
+ {
+ return;
+ }
+
+ foreach (var entry in metadata.Entries)
+ {
+ context.Log.Write(
+ AuthorizationInherited(entry.Coordinate, entry.Paths, entry.SourceSchemas));
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/AuthorizationTransitiveRequirementsRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/AuthorizationTransitiveRequirementsRule.cs
new file mode 100644
index 00000000000..4ea1002e567
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PostMergeValidationRules/AuthorizationTransitiveRequirementsRule.cs
@@ -0,0 +1,354 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Directives;
+using HotChocolate.Fusion.Events;
+using HotChocolate.Fusion.Events.Contracts;
+using HotChocolate.Fusion.Extensions;
+using HotChocolate.Fusion.Language;
+using HotChocolate.Fusion.SchemaVisitors;
+using HotChocolate.Fusion.Validators;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+using static HotChocolate.Fusion.Logging.LogEntryHelper;
+using static HotChocolate.Fusion.WellKnownArgumentNames;
+using static HotChocolate.Fusion.WellKnownDirectiveNames;
+
+namespace HotChocolate.Fusion.PostMergeValidationRules;
+
+///
+/// Fails composition with AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING when a field reads
+/// other fields through @require or a lookup key without declaring all of their
+/// authorization requirements. A requirement is declared when every alternative group of it
+/// contains a group of the dependency and @authenticated is present whenever the dependency
+/// has it.
+///
+internal sealed class AuthorizationTransitiveRequirementsRule : IEventHandler
+{
+ public void Handle(SchemaEvent @event, CompositionContext context)
+ {
+ var schema = @event.Schema;
+
+ if (!HasAuthorization(schema))
+ {
+ return;
+ }
+
+ var checker = new Checker(schema, context);
+
+ foreach (var sourceSchema in context.SchemaDefinitions)
+ {
+ checker.CheckRequireDirectives(sourceSchema);
+ checker.CheckLookupKeys(sourceSchema);
+ }
+ }
+
+ private static bool HasAuthorization(MutableSchemaDefinition schema)
+ {
+ foreach (var type in schema.Types.OfType())
+ {
+ foreach (var field in type.Fields)
+ {
+ if (field.Directives.ContainsName(FusionAuthorization))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private sealed class Checker(MutableSchemaDefinition schema, CompositionContext context)
+ {
+ private readonly FieldSelectionMapValidator _validator = new(schema);
+ private readonly HashSet<(string Requiring, string Dependency)> _reported = [];
+
+ public void CheckRequireDirectives(MutableSchemaDefinition sourceSchema)
+ {
+ foreach (var sourceType in sourceSchema.Types.OfType())
+ {
+ if (!schema.Types.TryGetType(
+ sourceType.Name,
+ out var mergedType))
+ {
+ continue;
+ }
+
+ foreach (var sourceField in sourceType.Fields)
+ {
+ if (!mergedType.Fields.TryGetField(sourceField.Name, out var mergedField))
+ {
+ continue;
+ }
+
+ foreach (var argument in sourceField.Arguments)
+ {
+ if (!argument.HasRequireDirective
+ || argument.Directives[Require].First().Arguments[Field].Value
+ is not string map)
+ {
+ continue;
+ }
+
+ var dependencies = SelectFields(map, argument, mergedType);
+
+ Check(
+ mergedField,
+ dependencies,
+ new SchemaCoordinate(sourceType.Name, sourceField.Name, argument.Name),
+ sourceSchema);
+ }
+ }
+ }
+ }
+
+ public void CheckLookupKeys(MutableSchemaDefinition sourceSchema)
+ {
+ var lookups = new DiscoverLookupsSchemaVisitor(sourceSchema).Discover();
+
+ foreach (var (_, lookupGroup) in lookups)
+ {
+ foreach (var (lookupField, _, _) in lookupGroup)
+ {
+ foreach (var sourceType in GetPossibleTypes(lookupField, sourceSchema))
+ {
+ if (schema.Types.TryGetType(
+ sourceType.Name,
+ out var mergedType))
+ {
+ CheckLookupKey(lookupField, sourceType, mergedType, sourceSchema);
+ }
+ }
+ }
+ }
+ }
+
+ private static IEnumerable GetPossibleTypes(
+ MutableOutputFieldDefinition lookupField,
+ MutableSchemaDefinition sourceSchema)
+ {
+ return lookupField.Type.AsTypeDefinition() switch
+ {
+ MutableObjectTypeDefinition objectType => [objectType],
+ MutableInterfaceTypeDefinition interfaceType
+ => sourceSchema.GetPossibleTypes(interfaceType).ToArray(),
+ MutableUnionTypeDefinition unionType
+ => sourceSchema.GetPossibleTypes(unionType).ToArray(),
+ _ => []
+ };
+ }
+
+ private void CheckLookupKey(
+ MutableOutputFieldDefinition lookupField,
+ MutableObjectTypeDefinition sourceType,
+ MutableComplexTypeDefinition mergedType,
+ MutableSchemaDefinition sourceSchema)
+ {
+ var dependencies = new List();
+
+ foreach (var argument in lookupField.Arguments)
+ {
+ if (!argument.HasRequireDirective)
+ {
+ dependencies.AddRange(
+ SelectFields(
+ argument.GetIsFieldSelectionMap() ?? argument.Name,
+ argument,
+ mergedType));
+ }
+ }
+
+ dependencies.RemoveAll(d => !IsServedByOtherSchema(d, sourceSchema));
+
+ var keyFieldNames = dependencies
+ .Where(d => d.Coordinate.Name == mergedType.Name)
+ .Select(d => d.Coordinate.MemberName)
+ .ToHashSet();
+
+ foreach (var servedField in sourceType.Fields)
+ {
+ if (servedField is { IsExternal: false, IsInternal: false, IsOverridden: false }
+ && !keyFieldNames.Contains(servedField.Name)
+ && mergedType.Fields.TryGetField(servedField.Name, out var mergedField))
+ {
+ Check(mergedField, dependencies, lookupField.Coordinate, sourceSchema);
+ }
+ }
+ }
+
+ private bool IsServedByOtherSchema(
+ IOutputFieldDefinition field,
+ MutableSchemaDefinition sourceSchema)
+ {
+ foreach (var other in context.SchemaDefinitions)
+ {
+ if (!ReferenceEquals(other, sourceSchema)
+ && other.Types.TryGetType(
+ field.Coordinate.Name,
+ out var otherType)
+ && otherType.Fields.TryGetField(field.Name, out var otherField)
+ && otherField is { IsExternal: false, IsInternal: false, IsOverridden: false })
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private List SelectFields(
+ string map,
+ MutableInputFieldDefinition argument,
+ MutableComplexTypeDefinition mergedType)
+ {
+ var inputTypeName = argument.Type.AsTypeDefinition().Name;
+
+ if (!schema.Types.TryGetType(inputTypeName, out var inputTypeDefinition))
+ {
+ return [];
+ }
+
+ var inputType = argument.Type.ToTypeNode().RewriteToType(inputTypeDefinition);
+
+ var selection = new FieldSelectionMapParser(map).Parse();
+ var selectedFields = new HashSet();
+
+ foreach (var variant in ExpandChoices(selection))
+ {
+ _validator.Validate(variant, inputType, mergedType, out var variantFields);
+ selectedFields.UnionWith(variantFields);
+ }
+
+ return [.. selectedFields.OrderBy(f => f.Coordinate.ToString(), StringComparer.Ordinal)];
+ }
+
+ private static IEnumerable ExpandChoices(IValueSelectionNode node)
+ {
+ switch (node)
+ {
+ case ChoiceValueSelectionNode choice:
+ foreach (var branch in choice.Branches)
+ {
+ foreach (var variant in ExpandChoices(branch))
+ {
+ yield return variant;
+ }
+ }
+
+ break;
+
+ case ObjectValueSelectionNode objectSelection:
+ foreach (var variant in ExpandObject(objectSelection))
+ {
+ yield return variant;
+ }
+
+ break;
+
+ case ListValueSelectionNode listSelection:
+ foreach (var variant in ExpandList(listSelection))
+ {
+ yield return variant;
+ }
+
+ break;
+
+ case PathObjectValueSelectionNode pathObject:
+ foreach (var variant in ExpandObject(pathObject.ObjectValueSelection))
+ {
+ yield return new PathObjectValueSelectionNode(
+ pathObject.Location,
+ pathObject.Path,
+ variant);
+ }
+
+ break;
+
+ case PathListValueSelectionNode pathList:
+ foreach (var variant in ExpandList(pathList.ListValueSelection))
+ {
+ yield return new PathListValueSelectionNode(
+ pathList.Location,
+ pathList.Path,
+ variant);
+ }
+
+ break;
+
+ default:
+ yield return node;
+ break;
+ }
+ }
+
+ private static IEnumerable ExpandObject(
+ ObjectValueSelectionNode node)
+ {
+ IEnumerable> combinations = [[]];
+
+ foreach (var field in node.Fields)
+ {
+ IValueSelectionNode?[] variants = field.ValueSelection is { } valueSelection
+ ? [.. ExpandChoices(valueSelection)]
+ : [null];
+ var current = combinations;
+
+ combinations = current.SelectMany(
+ prefix => variants.Select(
+ variant => prefix.Add(
+ new ObjectFieldSelectionNode(
+ field.Location,
+ field.Name,
+ field.Arguments,
+ variant))));
+ }
+
+ foreach (var fields in combinations)
+ {
+ yield return new ObjectValueSelectionNode(node.Location, fields);
+ }
+ }
+
+ private static IEnumerable ExpandList(ListValueSelectionNode node)
+ {
+ foreach (var variant in ExpandChoices(node.ElementSelection))
+ {
+ yield return new ListValueSelectionNode(node.Location, variant);
+ }
+ }
+
+ private void Check(
+ IOutputFieldDefinition requiring,
+ List dependencies,
+ SchemaCoordinate via,
+ MutableSchemaDefinition sourceSchema)
+ {
+ var requirement = GetRequirement(requiring);
+
+ foreach (var dependency in dependencies)
+ {
+ var uncovered = requirement.GetUncovered(GetRequirement(dependency));
+
+ if (uncovered.IsEmpty
+ || !_reported.Add((requiring.Coordinate.ToString(), dependency.Coordinate.ToString())))
+ {
+ continue;
+ }
+
+ context.Log.Write(
+ AuthorizationTransitiveRequirementsMissing(
+ requiring.Coordinate,
+ dependency.Coordinate,
+ via,
+ sourceSchema,
+ uncovered));
+ }
+ }
+
+ private static MergedAuthorization GetRequirement(IOutputFieldDefinition field)
+ {
+ return field.Directives.FirstOrDefault(FusionAuthorization) is { } directive
+ ? AuthorizationGroups.FromFusionDirective(directive)
+ : MergedAuthorization.Empty;
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/AuthorizationDirectiveRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/AuthorizationDirectiveRule.cs
new file mode 100644
index 00000000000..1ffe7a22399
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/AuthorizationDirectiveRule.cs
@@ -0,0 +1,96 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Directives;
+using HotChocolate.Fusion.Events;
+using HotChocolate.Fusion.Events.Contracts;
+using HotChocolate.Fusion.Info;
+using HotChocolate.Types;
+using static HotChocolate.Fusion.Logging.LogEntryHelper;
+using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames;
+
+namespace HotChocolate.Fusion.PreMergeValidationRules;
+
+///
+/// Warns when source schemas disagree on @authenticated for a shared type or field, and
+/// when the merged scopes or policies requirement of a member exceeds the group count threshold.
+///
+internal sealed class AuthorizationDirectiveRule
+ : IEventHandler
+ , IEventHandler
+{
+ ///
+ /// The number of reduced groups above which composition warns about a scopes or policies
+ /// requirement.
+ ///
+ private const int GroupCountThreshold = 64;
+
+ public void Handle(TypeGroupEvent @event, CompositionContext context)
+ {
+ var (typeName, typeGroup) = @event;
+
+ if (typeGroup[0].Type.Kind
+ is not (TypeKind.Object or TypeKind.Interface or TypeKind.Enum or TypeKind.Scalar))
+ {
+ return;
+ }
+
+ Validate(
+ new SchemaCoordinate(typeName),
+ [.. typeGroup.Select(static g => new DirectivesProviderInfo(g.Type, g.Schema))],
+ context);
+ }
+
+ public void Handle(OutputFieldGroupEvent @event, CompositionContext context)
+ {
+ var (fieldName, fieldGroup, typeName) = @event;
+
+ Validate(
+ new SchemaCoordinate(typeName, fieldName, ofDirective: false),
+ [.. fieldGroup.Select(static g => new DirectivesProviderInfo(g.Field, g.Schema))],
+ context);
+ }
+
+ private static void Validate(
+ SchemaCoordinate coordinate,
+ ImmutableArray memberDefinitions,
+ CompositionContext context)
+ {
+ var marked = memberDefinitions
+ .Where(static m => m.Member.Directives.Any(static d => d.Name == DirectiveNames.Authenticated))
+ .ToImmutableArray();
+
+ if (marked.Length > 0 && marked.Length < memberDefinitions.Length)
+ {
+ context.Log.Write(
+ AuthenticatedMismatch(
+ coordinate,
+ marked[0].Schema,
+ marked.Select(static m => m.Schema.Name),
+ memberDefinitions.Except(marked).Select(static m => m.Schema.Name)));
+ }
+
+ var merged = AuthorizationGroups.Merge(memberDefinitions);
+ var schema = memberDefinitions[0].Schema;
+
+ if (merged.Scopes.Length > GroupCountThreshold)
+ {
+ context.Log.Write(
+ AuthorizationGroupCountExceeded(
+ "scopes",
+ coordinate,
+ schema,
+ merged.Scopes.Length,
+ GroupCountThreshold));
+ }
+
+ if (merged.Policies.Length > GroupCountThreshold)
+ {
+ context.Log.Write(
+ AuthorizationGroupCountExceeded(
+ "policies",
+ coordinate,
+ schema,
+ merged.Policies.Length,
+ GroupCountThreshold));
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs
index 436d8f65e0c..fc9ba142ed5 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs
@@ -105,38 +105,29 @@ internal static string ApolloFederationSchemaFetcher_RequestFailed {
}
///
- /// Looks up a localized string similar to The 'apply' argument of the @authorize directive must be of type ApplyPolicy..
+ /// Looks up a localized string similar to The @authenticated directive requires the client to be authenticated..
///
- internal static string AuthorizeDirective_ApplyArgument_Invalid {
+ internal static string AuthenticatedMutableDirectiveDefinition_Description {
get {
- return ResourceManager.GetString("AuthorizeDirective_ApplyArgument_Invalid", resourceCulture);
+ return ResourceManager.GetString("AuthenticatedMutableDirectiveDefinition_Description", resourceCulture);
}
}
///
- /// Looks up a localized string similar to The value '{0}' for argument 'apply' in the @authorize directive is invalid..
+ /// Looks up a localized string similar to The '{1}' argument of the @{0} directive must be a list of string lists..
///
- internal static string AuthorizeDirective_ApplyArgument_InvalidEnumValue {
+ internal static string AuthorizationDirective_Argument_Invalid {
get {
- return ResourceManager.GetString("AuthorizeDirective_ApplyArgument_InvalidEnumValue", resourceCulture);
+ return ResourceManager.GetString("AuthorizationDirective_Argument_Invalid", resourceCulture);
}
}
///
- /// Looks up a localized string similar to The 'policy' argument of the @authorize directive must be of type String..
+ /// Looks up a localized string similar to The type kind '{0}' does not carry authorization directives..
///
- internal static string AuthorizeDirective_PolicyArgument_Invalid {
+ internal static string AuthorizationInheritance_UnexpectedTypeKind {
get {
- return ResourceManager.GetString("AuthorizeDirective_PolicyArgument_Invalid", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to The 'roles' argument of the @authorize directive must be of type [String!]..
- ///
- internal static string AuthorizeDirective_RolesArgument_Invalid {
- get {
- return ResourceManager.GetString("AuthorizeDirective_RolesArgument_Invalid", resourceCulture);
+ return ResourceManager.GetString("AuthorizationInheritance_UnexpectedTypeKind", resourceCulture);
}
}
@@ -239,6 +230,42 @@ internal static string ExternalMutableDirectiveDefinition_Description {
}
}
+ ///
+ /// Looks up a localized string similar to Whether the member requires an authenticated user..
+ ///
+ internal static string FusionAuthorizationMutableDirectiveDefinition_Argument_Authenticated_Description {
+ get {
+ return ResourceManager.GetString("FusionAuthorizationMutableDirectiveDefinition_Argument_Authenticated_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass..
+ ///
+ internal static string FusionAuthorizationMutableDirectiveDefinition_Argument_Policies_Description {
+ get {
+ return ResourceManager.GetString("FusionAuthorizationMutableDirectiveDefinition_Argument_Policies_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present..
+ ///
+ internal static string FusionAuthorizationMutableDirectiveDefinition_Argument_Scopes_Description {
+ get {
+ return ResourceManager.GetString("FusionAuthorizationMutableDirectiveDefinition_Argument_Scopes_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The @fusion__authorization directive carries the merged authorization requirement of a type system member. The authenticated, scopes and policies parts are independent and all must be satisfied..
+ ///
+ internal static string FusionAuthorizationMutableDirectiveDefinition_Description {
+ get {
+ return ResourceManager.GetString("FusionAuthorizationMutableDirectiveDefinition_Description", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The name of the source schema that defined the cost metadata..
///
@@ -817,6 +844,60 @@ internal static string ListSizeDirective_SlicingArgumentsArgument_Invalid {
}
}
+ ///
+ /// Looks up a localized string similar to The member '{0}' is marked with @authenticated in the source schemas {1} but not in the source schemas {2}. The composed member requires authentication..
+ ///
+ internal static string LogEntryHelper_AuthenticatedMismatch {
+ get {
+ return ResourceManager.GetString("LogEntryHelper_AuthenticatedMismatch", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The '{1}' argument of the @{0} directive on '{2}' in schema '{3}' must be a non-blank string or a non-empty list of non-blank strings and non-empty lists of non-blank strings, but has the value {4}..
+ ///
+ internal static string LogEntryHelper_AuthorizationDirectiveArgumentInvalid {
+ get {
+ return ResourceManager.GetString("LogEntryHelper_AuthorizationDirectiveArgumentInvalid", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The merged {0} requirement of '{1}' has {2} alternative groups, which exceeds the threshold of {3}..
+ ///
+ internal static string LogEntryHelper_AuthorizationGroupCountExceeded {
+ get {
+ return ResourceManager.GetString("LogEntryHelper_AuthorizationGroupCountExceeded", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The member '{0}' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from {1} in the source schemas {2}..
+ ///
+ internal static string LogEntryHelper_AuthorizationInherited {
+ get {
+ return ResourceManager.GetString("LogEntryHelper_AuthorizationInherited", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The @interfaceObject type '{0}' in schema '{1}' cannot be annotated with @{2}. Annotate its fields instead..
+ ///
+ internal static string LogEntryHelper_AuthorizationOnInterfaceObject {
+ get {
+ return ResourceManager.GetString("LogEntryHelper_AuthorizationOnInterfaceObject", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The field '{0}' depends on '{1}' through '{2}' in schema '{3}', but does not declare all authorization requirements of '{1}'. Not covered: {4}..
+ ///
+ internal static string LogEntryHelper_AuthorizationTransitiveRequirementsMissing {
+ get {
+ return ResourceManager.GetString("LogEntryHelper_AuthorizationTransitiveRequirementsMissing", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The built-in scalar type '{0}' in schema '{1}' is not accessible..
///
@@ -1699,6 +1780,24 @@ internal static string OverrideMutableDirectiveDefinition_Description {
}
}
+ ///
+ /// Looks up a localized string similar to The policy groups of which at least one must be fully satisfied..
+ ///
+ internal static string PolicyMutableDirectiveDefinition_Argument_Policies_Description {
+ get {
+ return ResourceManager.GetString("PolicyMutableDirectiveDefinition_Argument_Policies_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The @policy directive requires the client to satisfy all policies of at least one of the policy groups..
+ ///
+ internal static string PolicyMutableDirectiveDefinition_Description {
+ get {
+ return ResourceManager.GetString("PolicyMutableDirectiveDefinition_Description", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Represents a selection set syntax describing the subfields of the returned type that can be provided by the current source schema..
///
@@ -1735,6 +1834,24 @@ internal static string RequiresMutableDirectiveDefinition_Description {
}
}
+ ///
+ /// Looks up a localized string similar to The scope groups of which at least one must be fully granted..
+ ///
+ internal static string RequiresScopesMutableDirectiveDefinition_Argument_Scopes_Description {
+ get {
+ return ResourceManager.GetString("RequiresScopesMutableDirectiveDefinition_Argument_Scopes_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The @requiresScopes directive requires the client to be granted all scopes of at least one of the scope groups..
+ ///
+ internal static string RequiresScopesMutableDirectiveDefinition_Description {
+ get {
+ return ResourceManager.GetString("RequiresScopesMutableDirectiveDefinition_Description", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to The required field '{0}' must not be defined in schema '{1}'..
///
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx
index c55bba88f63..71898d8fc14 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx
@@ -33,17 +33,14 @@
Source schema '{0}' returned HTTP {1} ({2}) for the _service query.
-
- The 'apply' argument of the @authorize directive must be of type ApplyPolicy.
+
+ The @authenticated directive requires the client to be authenticated.
-
- The value '{0}' for argument 'apply' in the @authorize directive is invalid.
+
+ The '{1}' argument of the @{0} directive must be a list of string lists.
-
- The 'policy' argument of the @authorize directive must be of type String.
-
-
- The 'roles' argument of the @authorize directive must be of type [String!].
+
+ The type kind '{0}' does not carry authorization directives.
{0} (Schema: '{1}')
@@ -75,6 +72,36 @@
Source schema validation failed.
+
+ Whether the member requires an authenticated user.
+
+
+ The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass.
+
+
+ The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present.
+
+
+ The @fusion__authorization directive carries the merged authorization requirement of a type system member. The authenticated, scopes and policies parts are independent and all must be satisfied.
+
+
+ The member '{0}' is marked with @authenticated in the source schemas {1} but not in the source schemas {2}. The composed member requires authentication.
+
+
+ The '{1}' argument of the @{0} directive on '{2}' in schema '{3}' must be a non-blank string or a non-empty list of non-blank strings and non-empty lists of non-blank strings, but has the value {4}.
+
+
+ The merged {0} requirement of '{1}' has {2} alternative groups, which exceeds the threshold of {3}.
+
+
+ The member '{0}' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from {1} in the source schemas {2}.
+
+
+ The @interfaceObject type '{0}' in schema '{1}' cannot be annotated with @{2}. Annotate its fields instead.
+
+
+ The field '{0}' depends on '{1}' through '{2}' in schema '{3}', but does not declare all authorization requirements of '{1}'. Not covered: {4}.
+
Source schema '{0}' returned a response larger than the maximum allowed size of {1} bytes.
@@ -579,6 +606,12 @@
The @override directive is used to migrate a field from one source schema to another.
+
+ The policy groups of which at least one must be fully satisfied.
+
+
+ The @policy directive requires the client to satisfy all policies of at least one of the policy groups.
+
Represents a selection set syntax describing the subfields of the returned type that can be provided by the current source schema.
@@ -591,6 +624,12 @@
The @requires directive expresses that a field depends on the given selection of fields from the same entity, resolved by another source schema.
+
+ The scope groups of which at least one must be fully granted.
+
+
+ The @requiresScopes directive requires the client to be granted all scopes of at least one of the scope groups.
+
The required field '{0}' must not be defined in schema '{1}'.
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs
index 307fd01cba7..24bd0c87b80 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs
@@ -255,6 +255,8 @@ private CompositionError InvalidShareableFieldRuntimeTypeRouting(string message)
///
internal static ImmutableArray