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 SourceSchemaRules { get; } = [ + new AuthorizationDirectiveArgumentRule(), + new AuthorizationOnInterfaceObjectRule(), new CostDirectiveDefinitionRule(), new DisallowedInaccessibleElementsRule(), new ExternalOnInterfaceRule(), @@ -302,6 +304,7 @@ private CompositionError InvalidShareableFieldRuntimeTypeRouting(string message) /// internal static ImmutableArray CreatePreMergeRules(SchemaComposerOptions options) => [ + new AuthorizationDirectiveRule(), new EnumValuesMismatchRule(options.Merger.EnumValuesMergeBehavior), new ExternalArgumentDefaultMismatchRule(), new ExternalArgumentMissingRule(), @@ -329,6 +332,8 @@ internal static ImmutableArray CreatePreMergeRules(SchemaComposerOptions /// internal static ImmutableArray PostMergeRules { get; } = [ + new AuthorizationInheritedRule(), + new AuthorizationTransitiveRequirementsRule(), new EmptyMergedEnumTypeRule(), new EmptyMergedInputObjectTypeRule(), new EmptyMergedInterfaceTypeRule(), diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs index b370378c173..651c6d2f1a6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs @@ -82,8 +82,9 @@ public SourceSchemaMerger( new Dictionary { { - DirectiveNames.Authorize, - new AuthorizeDirectiveMerger(DirectiveMergeBehavior.Include) + DirectiveNames.FusionAuthorization, + new AuthorizationDirectiveMerger( + _fusionDirectiveDefinitions[DirectiveNames.FusionAuthorization]) }, { DirectiveNames.CacheControl, @@ -133,6 +134,10 @@ public CompositionResult Merge() SetOperationTypes(mergedSchema); AddFusionLookupDirectives(mergedSchema); AddNodeField(mergedSchema); + AuthorizationInheritance.Apply( + mergedSchema, + _fusionDirectiveDefinitions[DirectiveNames.FusionAuthorization], + _schemas); // Merge directives. var memberDefinitions = _schemas.Select(s => new DirectivesProviderInfo(s, s)).ToImmutableArray(); @@ -317,9 +322,12 @@ private void AddNodeField(MutableSchemaDefinition mergedSchema) && mergedSchema.Types.TryGetType(TypeNames.ID, out var idType) && mergedSchema.QueryType is { } queryType) { + Directive? nodeFieldAuthorization = null; + if (queryType.Fields.TryGetField(FieldNames.Node, out var nodeField) && IsGoiNodeField(nodeField, nodeType, idType)) { + nodeFieldAuthorization = nodeField.Directives.FirstOrDefault(DirectiveNames.FusionAuthorization); queryType.Fields.Remove(nodeField); } @@ -335,6 +343,12 @@ private void AddNodeField(MutableSchemaDefinition mergedSchema) var canonicalNodeField = new MutableOutputFieldDefinition(FieldNames.Node, nodeType); canonicalNodeField.Arguments.Add( new MutableInputFieldDefinition(ArgumentNames.Id, new NonNullType(idType))); + + if (nodeFieldAuthorization is not null) + { + canonicalNodeField.Directives.Add(nodeFieldAuthorization); + } + canonicalNodeField.Directives.Add( new Directive(_fusionDirectiveDefinitions[DirectiveNames.FusionGatewayField])); @@ -501,6 +515,8 @@ private MutableEnumTypeDefinition MergeEnumTypes( { var memberDefinitions = typeGroup.Select(g => new DirectivesProviderInfo(g.Type, g.Schema)).ToImmutableArray(); + _directiveMergers[DirectiveNames.FusionAuthorization] + .MergeDirectives(enumType, memberDefinitions, mergedSchema); DeriveCostDirectives(enumType, memberDefinitions, mergedSchema, CostCoordinateKind.LeafType, null); _directiveMergers[DirectiveNames.Tag].MergeDirectives(enumType, memberDefinitions, mergedSchema); @@ -751,6 +767,8 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( { var memberDefinitions = typeGroup.Select(g => new DirectivesProviderInfo(g.Type, g.Schema)).ToImmutableArray(); + _directiveMergers[DirectiveNames.FusionAuthorization] + .MergeDirectives(interfaceType, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.CacheControl].MergeDirectives(interfaceType, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.Tag].MergeDirectives(interfaceType, memberDefinitions, mergedSchema); @@ -844,7 +862,7 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( () => { var memberDefinitions = typeGroup.Select(g => new DirectivesProviderInfo(g.Type, g.Schema)).ToImmutableArray(); - _directiveMergers[DirectiveNames.Authorize] + _directiveMergers[DirectiveNames.FusionAuthorization] .MergeDirectives(objectType, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.CacheControl] .MergeDirectives(objectType, memberDefinitions, mergedSchema); @@ -972,7 +990,7 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( { var memberDefinitions = fieldGroup.Select(g => new DirectivesProviderInfo(g.Field, g.Schema)).ToImmutableArray(); - _directiveMergers[DirectiveNames.Authorize] + _directiveMergers[DirectiveNames.FusionAuthorization] .MergeDirectives(outputField, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.CacheControl] .MergeDirectives(outputField, memberDefinitions, mergedSchema); @@ -1039,6 +1057,8 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( { var memberDefinitions = typeGroup.Select(g => new DirectivesProviderInfo(g.Type, g.Schema)).ToImmutableArray(); + _directiveMergers[DirectiveNames.FusionAuthorization] + .MergeDirectives(scalarType, memberDefinitions, mergedSchema); DeriveCostDirectives(scalarType, memberDefinitions, mergedSchema, CostCoordinateKind.LeafType, null); _directiveMergers[DirectiveNames.SerializeAs] .MergeDirectives(scalarType, memberDefinitions, mergedSchema); @@ -2209,6 +2229,10 @@ private FrozenDictionary CreateFusionDirecti return new Dictionary { + { + DirectiveNames.FusionAuthorization, + new FusionAuthorizationMutableDirectiveDefinition(stringType, booleanType) + }, { DirectiveNames.FusionCost, new FusionCostMutableDirectiveDefinition(schemaEnumType, stringType) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs index bd2067914c3..0c6e7fdce98 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaParser.cs @@ -130,6 +130,15 @@ public CompositionResult Parse() } } + // Renamed and namespaced Apollo authorization directives are rewritten before validation + // so they bind to the Fusion definitions even when the subgraph does not declare them. + if (!isApolloFederationV1 + && !_log.HasErrors + && FederationSchemaTransformer.IsFederationSchema(schema)) + { + TransformAuthDirectivesToFusion.Apply(schema); + } + if (isApolloFederationV1 && FederationSchemaTransformer.IsFederationSchema(schema)) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs index 59d9b9faa31..c2e254f3ca2 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaPreprocessor.cs @@ -47,6 +47,7 @@ public CompositionResult Preprocess() if (isFederationSchema) { SourceExternalFieldMetadata.Capture(schema); + TransformAuthDirectivesToFusion.Apply(schema); RemoveFederationInfrastructure.Apply(schema); GenerateLookupFields.Apply(schema); RewriteKeyDirectives.Apply(schema); diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/AuthorizationDirectiveArgumentRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/AuthorizationDirectiveArgumentRule.cs new file mode 100644 index 00000000000..de97c4a69d1 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/AuthorizationDirectiveArgumentRule.cs @@ -0,0 +1,126 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; +using ArgumentNames = HotChocolate.Fusion.WellKnownArgumentNames; +using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames; + +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +/// +/// Reports @requiresScopes and @policy directives whose argument is missing, null, +/// not a string, a list of strings or a list of string lists, or contains an empty list, an empty +/// group or a blank string. +/// +internal sealed class AuthorizationDirectiveArgumentRule + : IEventHandler + , IEventHandler +{ + public void Handle(OutputFieldEvent @event, CompositionContext context) + { + var (field, _, schema) = @event; + + Validate(field, field.Coordinate, schema, context); + } + + public void Handle(TypeEvent @event, CompositionContext context) + { + var (type, schema) = @event; + + switch (type) + { + case MutableObjectTypeDefinition objectType: + Validate(objectType, objectType.Coordinate, schema, context); + break; + + case MutableInterfaceTypeDefinition interfaceType: + Validate(interfaceType, interfaceType.Coordinate, schema, context); + break; + + case MutableEnumTypeDefinition enumType: + Validate(enumType, enumType.Coordinate, schema, context); + break; + + case MutableScalarTypeDefinition scalarType: + Validate(scalarType, scalarType.Coordinate, schema, context); + break; + } + } + + private static void Validate( + IDirectivesProvider member, + SchemaCoordinate coordinate, + MutableSchemaDefinition schema, + CompositionContext context) + { + foreach (var directive in member.Directives) + { + var argumentName = directive.Name switch + { + DirectiveNames.RequiresScopes => ArgumentNames.Scopes, + DirectiveNames.Policy => ArgumentNames.Policies, + _ => null + }; + + if (argumentName is null) + { + continue; + } + + directive.Arguments.TryGetValue(argumentName, out var value); + + if (!IsValid(value)) + { + context.Log.Write( + AuthorizationDirectiveArgumentInvalid( + directive.Name, + argumentName, + value, + coordinate, + schema)); + } + } + } + + // GraphQL input coercion permits a single string wherever a list of string lists is expected. + private static bool IsValid(IValueNode? value) + { + switch (value) + { + case StringValueNode single: + return !string.IsNullOrWhiteSpace(single.Value); + + case ListValueNode { Items.Count: > 0 } list: + foreach (var item in list.Items) + { + switch (item) + { + case StringValueNode factor when !string.IsNullOrWhiteSpace(factor.Value): + continue; + + case ListValueNode { Items.Count: > 0 } group: + foreach (var scope in group.Items) + { + if (scope is not StringValueNode { Value: var text } + || string.IsNullOrWhiteSpace(text)) + { + return false; + } + } + + continue; + + default: + return false; + } + } + + return true; + + default: + return false; + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/AuthorizationOnInterfaceObjectRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/AuthorizationOnInterfaceObjectRule.cs new file mode 100644 index 00000000000..6d1f40674f4 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaValidationRules/AuthorizationOnInterfaceObjectRule.cs @@ -0,0 +1,35 @@ +using HotChocolate.Fusion.Events; +using HotChocolate.Fusion.Events.Contracts; +using HotChocolate.Types.Mutable; +using static HotChocolate.Fusion.Logging.LogEntryHelper; +using DirectiveNames = HotChocolate.Fusion.WellKnownDirectiveNames; + +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +/// +/// Reports @authenticated, @requiresScopes and @policy on a type that is +/// marked with @interfaceObject. The fields of such a type can be annotated. +/// +internal sealed class AuthorizationOnInterfaceObjectRule : IEventHandler +{ + public void Handle(ComplexTypeEvent @event, CompositionContext context) + { + var (complexType, schema) = @event; + + if (complexType is not MutableObjectTypeDefinition standIn + || !standIn.Directives.ContainsName(DirectiveNames.InterfaceObject)) + { + return; + } + + foreach (var directive in standIn.Directives.AsEnumerable()) + { + if (directive.Name is DirectiveNames.Authenticated + or DirectiveNames.RequiresScopes + or DirectiveNames.Policy) + { + context.Log.Write(AuthorizationOnInterfaceObject(standIn, directive.Name, schema)); + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs index 8520fae883f..62cc781f450 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs @@ -1,10 +1,12 @@ +using HotChocolate.Types; + namespace HotChocolate.Fusion; internal static class WellKnownArgumentNames { - public const string Apply = "apply"; public const string AllowNonResolvableInterfaceObjects = "allowNonResolvableInterfaceObjects"; public const string AssumedSize = "assumedSize"; + public const string Authenticated = "authenticated"; public const string Broker = "broker"; public const string CursorArgument = "cursorArgument"; public const string CursorField = "cursorField"; @@ -31,14 +33,14 @@ internal static class WellKnownArgumentNames public const string Partial = "partial"; public const string Path = "path"; public const string Pattern = "pattern"; - public const string Policy = "policy"; + public const string Policies = DirectiveNames.Policy.Arguments.Policies; public const string Provides = "provides"; public const string Requirements = "requirements"; public const string RequireOneSlicingArgument = "requireOneSlicingArgument"; - public const string Roles = "roles"; public const string Schema = "schema"; public const string ShareableFieldRuntimeTypeRouting = "shareableFieldRuntimeTypeRouting"; public const string Scope = "scope"; + public const string Scopes = DirectiveNames.RequiresScopes.Arguments.Scopes; public const string SharedMaxAge = "sharedMaxAge"; public const string SizedFields = "sizedFields"; public const string SlicingArgumentDefaultValue = "slicingArgumentDefaultValue"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs index a3e9961a95c..8c05cd0d725 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs @@ -4,11 +4,12 @@ namespace HotChocolate.Fusion; internal static class WellKnownDirectiveNames { - public const string Authorize = "authorize"; + public const string Authenticated = DirectiveNames.Authenticated.Name; public const string CacheControl = DirectiveNames.CacheControl.Name; public const string Cost = DirectiveNames.Cost.Name; public const string EventCursor = DirectiveNames.EventCursor.Name; public const string External = DirectiveNames.External.Name; + public const string FusionAuthorization = "fusion__authorization"; public const string FusionCost = "fusion__cost"; public const string FusionCostOptions = "fusion__cost_options"; public const string FusionEnumValue = "fusion__enumValue"; @@ -38,9 +39,11 @@ internal static class WellKnownDirectiveNames public const string OneOf = DirectiveNames.OneOf.Name; public const string OptInFeatureStability = DirectiveNames.OptInFeatureStability.Name; public const string Override = DirectiveNames.Override.Name; + public const string Policy = DirectiveNames.Policy.Name; public const string Provides = DirectiveNames.Provides.Name; public const string Require = DirectiveNames.Require.Name; public const string RequiresOptIn = DirectiveNames.RequiresOptIn.Name; + public const string RequiresScopes = DirectiveNames.RequiresScopes.Name; public const string SerializeAs = DirectiveNames.SerializeAs.Name; public const string Shareable = DirectiveNames.Shareable.Name; public const string SpecifiedBy = DirectiveNames.SpecifiedBy.Name; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs index b60b27f01ec..0b19f5a139b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs @@ -2,7 +2,6 @@ namespace HotChocolate.Fusion; internal static class WellKnownTypeNames { - public const string ApplyPolicy = "ApplyPolicy"; public const string CacheControlScope = "CacheControlScope"; public const string FieldSelectionMap = "FieldSelectionMap"; public const string FieldSelectionSet = "FieldSelectionSet"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs index 6065f28e840..747737defe8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -249,7 +249,10 @@ private static FusionObjectTypeDefinition CreateObjectType( definition.Description?.Value, deprecationReason, isInaccessible, - CreateOutputFields(definition.Fields, isQuery, enableSemanticIntrospection)); + CreateOutputFields(definition.Fields, isQuery, enableSemanticIntrospection)) + { + Authorization = AuthorizationDirectiveParser.Parse(definition.Directives) + }; } private static FusionInterfaceTypeDefinition CreateInterfaceType( @@ -261,7 +264,10 @@ private static FusionInterfaceTypeDefinition CreateInterfaceType( definition.Name.Value, definition.Description?.Value, isInaccessible, - CreateOutputFields(definition.Fields, isQuery: false, enableSemanticIntrospection: false)); + CreateOutputFields(definition.Fields, isQuery: false, enableSemanticIntrospection: false)) + { + Authorization = AuthorizationDirectiveParser.Parse(definition.Directives) + }; } private static FusionUnionTypeDefinition CreateUnionType( @@ -296,7 +302,10 @@ private static FusionEnumTypeDefinition CreateEnumType( definition.Name.Value, definition.Description?.Value, isInaccessible, - CreateEnumValues(definition.Values)); + CreateEnumValues(definition.Values)) + { + Authorization = AuthorizationDirectiveParser.Parse(definition.Directives) + }; } private static FusionScalarTypeDefinition CreateScalarType( @@ -307,7 +316,10 @@ private static FusionScalarTypeDefinition CreateScalarType( return new FusionScalarTypeDefinition( definition.Name.Value, definition.Description?.Value, - isInaccessible); + isInaccessible) + { + Authorization = AuthorizationDirectiveParser.Parse(definition.Directives) + }; } private static FusionDirectiveDefinition CreateDirectiveType( @@ -458,7 +470,10 @@ private static FusionOutputFieldDefinitionCollection CreateOutputFields( deprecationReason, isInaccessible: isInaccessible, isGatewayField: isGatewayField, - CreateOutputFieldArguments(field.Arguments)); + CreateOutputFieldArguments(field.Arguments)) + { + Authorization = AuthorizationDirectiveParser.Parse(field.Directives) + }; } } else @@ -476,7 +491,10 @@ private static FusionOutputFieldDefinitionCollection CreateOutputFields( deprecationReason, isInaccessible: isInaccessible, isGatewayField: isGatewayField, - CreateOutputFieldArguments(field.Arguments)); + CreateOutputFieldArguments(field.Arguments)) + { + Authorization = AuthorizationDirectiveParser.Parse(field.Directives) + }; } } @@ -638,6 +656,8 @@ private static FusionSchemaDefinition CompleteTypes( features.Set(CollectOptInFeatures(context)); } + features.Set(CollectAuthorizationUsage(context)); + context.Interceptor.OnBeforeCompleteSchema(context, ref features); features.Set(null); @@ -1435,6 +1455,69 @@ static void CollectFromDirectives( } } + private static FusionAuthorizationUsage CollectAuthorizationUsage(CompositeSchemaBuilderContext context) + { + var flags = FusionAuthorizationUsageFlags.None; + var policyNames = new HashSet(StringComparer.Ordinal); + + foreach (var type in context.TypeDefinitions) + { + switch (type) + { + case FusionComplexTypeDefinition complexType: + Add(complexType.Authorization); + + foreach (var field in complexType.Fields) + { + Add(field.Authorization); + } + + break; + + case FusionEnumTypeDefinition enumType: + Add(enumType.Authorization); + break; + + case FusionScalarTypeDefinition scalarType: + Add(scalarType.Authorization); + break; + } + } + + var sortedPolicyNames = new string[policyNames.Count]; + policyNames.CopyTo(sortedPolicyNames); + Array.Sort(sortedPolicyNames, StringComparer.Ordinal); + + return new FusionAuthorizationUsage(flags, AsImmutableArray(sortedPolicyNames)); + + void Add(AuthorizationDirective? authorization) + { + if (authorization is null) + { + return; + } + + if (authorization.Authenticated) + { + flags |= FusionAuthorizationUsageFlags.Authenticated; + } + + if (authorization.Scopes.Length > 0) + { + flags |= FusionAuthorizationUsageFlags.Scopes; + } + + foreach (var group in authorization.Policies) + { + foreach (var name in group) + { + flags |= FusionAuthorizationUsageFlags.Policies; + policyNames.Add(name); + } + } + } + } + private sealed class EmptyServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/AuthorizationDirective.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/AuthorizationDirective.cs new file mode 100644 index 00000000000..96e99fa37c3 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/AuthorizationDirective.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Types.Directives; + +/// +/// The merged authorization requirement of a type system member as written by composition. +/// The authenticated, scopes and policies parts are independent and all must be satisfied. +/// +internal sealed class AuthorizationDirective( + bool authenticated, + ImmutableArray> scopes, + ImmutableArray> policies) +{ + /// + /// Gets a value indicating whether the member requires an authenticated user. + /// + public bool Authenticated { get; } = authenticated; + + /// + /// Gets the alternative scope groups in canonical order, or an empty array if no scopes are + /// required. One fully granted group satisfies the requirement. + /// + public ImmutableArray> Scopes { get; } = scopes; + + /// + /// Gets the alternative policy groups in canonical order, or an empty array if no policies + /// are required. One fully satisfied group satisfies the requirement. + /// + public ImmutableArray> Policies { get; } = policies; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/AuthorizationDirectiveParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/AuthorizationDirectiveParser.cs new file mode 100644 index 00000000000..cd4152550b0 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Directives/AuthorizationDirectiveParser.cs @@ -0,0 +1,92 @@ +using System.Collections.Immutable; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Types.Directives; + +internal static class AuthorizationDirectiveParser +{ + public static bool CanParse(DirectiveNode directiveNode) + => directiveNode.Name.Value.Equals(FusionBuiltIns.Authorization, StringComparison.Ordinal); + + public static AuthorizationDirective? Parse(IReadOnlyList directiveNodes) + { + for (var i = 0; i < directiveNodes.Count; i++) + { + if (CanParse(directiveNodes[i])) + { + return Parse(directiveNodes[i]); + } + } + + return null; + } + + public static AuthorizationDirective Parse(DirectiveNode directiveNode) + { + var authenticated = false; + ImmutableArray> scopes = []; + ImmutableArray> policies = []; + + foreach (var argument in directiveNode.Arguments) + { + switch (argument.Name.Value) + { + case "authenticated": + authenticated = argument.Value is BooleanValueNode { Value: true }; + break; + + case "scopes": + scopes = ParseGroups(argument); + break; + + case "policies": + policies = ParseGroups(argument); + break; + + default: + throw ThrowHelper.AuthorizationDirectiveArgumentNotSupported(argument.Name.Value); + } + } + + return new AuthorizationDirective(authenticated, scopes, policies); + } + + private static ImmutableArray> ParseGroups(ArgumentNode argument) + { + if (argument.Value is NullValueNode) + { + return []; + } + + if (argument.Value is not ListValueNode outer) + { + throw ThrowHelper.AuthorizationDirectiveGroupsInvalid(argument.Name.Value); + } + + var groups = ImmutableArray.CreateBuilder>(outer.Items.Count); + + foreach (var item in outer.Items) + { + if (item is not ListValueNode inner) + { + throw ThrowHelper.AuthorizationDirectiveGroupsInvalid(argument.Name.Value); + } + + var values = ImmutableArray.CreateBuilder(inner.Items.Count); + + foreach (var value in inner.Items) + { + if (value is not StringValueNode stringValue) + { + throw ThrowHelper.AuthorizationDirectiveGroupsInvalid(argument.Name.Value); + } + + values.Add(stringValue.Value); + } + + groups.Add(values.ToImmutable()); + } + + return groups.ToImmutable(); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs index a1ddb167c30..f9ca7dbd2d0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionBuiltIns.cs @@ -20,6 +20,7 @@ internal static class FusionBuiltIns public const string InterfaceObject = "fusion__interfaceObject"; public const string UnionMember = "fusion__unionMember"; public const string EnumValue = "fusion__enumValue"; + public const string Authorization = "fusion__authorization"; public const string Execution = "fusion__execution"; public const string CostOptions = "fusion__cost_options"; public const string Inaccessible = "fusion__inaccessible"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs index 8c8351ed69c..9197f79ef3d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionComplexTypeDefinition.cs @@ -1,5 +1,6 @@ using HotChocolate.Features; using HotChocolate.Fusion.Types.Collections; +using HotChocolate.Fusion.Types.Directives; using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; using HotChocolate.Serialization; @@ -54,6 +55,11 @@ protected FusionComplexTypeDefinition( Type IRuntimeTypeProvider.RuntimeType => typeof(object); + /// + /// Gets the merged authorization requirement, or null if the type has none. + /// + internal AuthorizationDirective? Authorization { get; init; } + /// /// Gets a value indicating whether this type is marked as inaccessible. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionEnumTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionEnumTypeDefinition.cs index d81d513dbe4..9a6083e35ad 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionEnumTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionEnumTypeDefinition.cs @@ -1,6 +1,7 @@ using HotChocolate.Features; using HotChocolate.Fusion.Types.Collections; using HotChocolate.Fusion.Types.Completion; +using HotChocolate.Fusion.Types.Directives; using HotChocolate.Language; using HotChocolate.Serialization; using HotChocolate.Types; @@ -65,6 +66,11 @@ public FusionEnumTypeDefinition( Type IRuntimeTypeProvider.RuntimeType => typeof(object); + /// + /// Gets the merged authorization requirement, or null if the enum type has none. + /// + internal AuthorizationDirective? Authorization { get; init; } + /// /// Gets a value indicating whether this enum type is marked as inaccessible. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionOutputFieldDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionOutputFieldDefinition.cs index ba3e43a6f83..a7452380e33 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionOutputFieldDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionOutputFieldDefinition.cs @@ -124,6 +124,28 @@ private set /// public string? DeprecationReason { get; } + /// + /// Gets the merged authorization requirement, or null if the field has none. + /// + internal AuthorizationDirective? Authorization + { + get; + init + { + field = value; + + if (value is not null) + { + _flags |= FieldDefinitionFlags.HasAuthorization; + } + } + } + + /// + /// Gets a value indicating whether this field carries an authorization requirement. + /// + public bool HasAuthorization => (_flags & FieldDefinitionFlags.HasAuthorization) == FieldDefinitionFlags.HasAuthorization; + /// /// Gets a value indicating whether this field is marked as inaccessible. /// @@ -272,6 +294,7 @@ private enum FieldDefinitionFlags : byte None = 0, Introspection = 1, Inaccessible = 2, - GatewayField = 4 + GatewayField = 4, + HasAuthorization = 8 } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs index a4c11f764e8..75479a919b6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionScalarTypeDefinition.cs @@ -1,6 +1,7 @@ using HotChocolate.Features; using HotChocolate.Fusion.Types.Collections; using HotChocolate.Fusion.Types.Completion; +using HotChocolate.Fusion.Types.Directives; using HotChocolate.Language; using HotChocolate.Serialization; using HotChocolate.Types; @@ -61,6 +62,11 @@ public FusionScalarTypeDefinition( Type IRuntimeTypeProvider.RuntimeType => typeof(object); + /// + /// Gets the merged authorization requirement, or null if the scalar type has none. + /// + internal AuthorizationDirective? Authorization { get; init; } + /// /// Gets a value indicating whether this scalar type is marked as inaccessible. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/FusionAuthorizationUsage.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/FusionAuthorizationUsage.cs new file mode 100644 index 00000000000..e0edb2753ef --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/FusionAuthorizationUsage.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Types.Metadata; + +/// +/// Holds which authorization requirements are used across the composed schema and the distinct +/// policy names they reference. Stored as a schema feature. +/// +internal sealed class FusionAuthorizationUsage( + FusionAuthorizationUsageFlags flags, + ImmutableArray policyNames) +{ + /// + /// Gets a value indicating whether any member requires an authenticated user. + /// + public bool UsesAuthenticated => (flags & FusionAuthorizationUsageFlags.Authenticated) != 0; + + /// + /// Gets a value indicating whether any member requires scopes. + /// + public bool UsesScopes => (flags & FusionAuthorizationUsageFlags.Scopes) != 0; + + /// + /// Gets a value indicating whether any member requires policies. + /// + public bool UsesPolicies => (flags & FusionAuthorizationUsageFlags.Policies) != 0; + + /// + /// Gets a value indicating whether any member carries an authorization requirement. + /// + public bool IsUsed => flags != FusionAuthorizationUsageFlags.None; + + /// + /// Gets the distinct policy names in ordinal order. + /// + public ImmutableArray PolicyNames { get; } = policyNames; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/FusionAuthorizationUsageFlags.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/FusionAuthorizationUsageFlags.cs new file mode 100644 index 00000000000..47d9bee223f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Metadata/FusionAuthorizationUsageFlags.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.Types.Metadata; + +/// +/// Describes which kinds of authorization requirements are used across the composed schema. +/// +[Flags] +internal enum FusionAuthorizationUsageFlags : byte +{ + None = 0, + Authenticated = 1, + Scopes = 2, + Policies = 4 +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/ThrowHelper.cs index e019e74611f..536f61c772b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/ThrowHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/ThrowHelper.cs @@ -1,3 +1,5 @@ +using HotChocolate.Fusion.Types.Directives; + namespace HotChocolate.Fusion.Types; internal static class ThrowHelper @@ -17,4 +19,10 @@ public static NotSupportedException TypeSystemMemberSealed() public static InvalidOperationException InvalidCompletionContext() => new("The context has an invalid state."); + + public static DirectiveParserException AuthorizationDirectiveArgumentNotSupported(string argumentName) + => new($"The argument `{argumentName}` is not supported on @fusion__authorization."); + + public static DirectiveParserException AuthorizationDirectiveGroupsInvalid(string argumentName) + => new($"The `{argumentName}` argument of @fusion__authorization must be a list of string lists."); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthenticatedPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthenticatedPolicy.cs new file mode 100644 index 00000000000..d5c733b06ee --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthenticatedPolicy.cs @@ -0,0 +1,41 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// The built-in policy of the @authenticated directive. It allows every entry when the +/// principal is authenticated and denies every entry otherwise. +/// +public sealed class AuthenticatedPolicy : IPolicy +{ + private AuthenticatedPolicy() + { + } + + /// + /// Gets the shared instance. + /// + public static AuthenticatedPolicy Instance { get; } = new(); + + /// + public ValueTask EvaluateAsync( + PolicyEvaluationContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var isAuthenticated = context.User.Identity?.IsAuthenticated is true; + + foreach (ref readonly var entry in context.Entries) + { + if (isAuthenticated) + { + context.Allow(in entry); + } + else + { + context.Deny(in entry); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthenticationSchemeResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthenticationSchemeResolver.cs new file mode 100644 index 00000000000..911f42fcf51 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthenticationSchemeResolver.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// Resolves the authentication schemes the gateway authenticates against from the +/// and the schemes the host has registered. +/// +internal sealed class AuthenticationSchemeResolver +{ + private readonly FusionAuthorizationOptions _options; + private readonly IAuthenticationSchemeLookup? _lookup; + + /// + /// Initializes a new instance of . + /// + /// + /// The authorization options. + /// + /// + /// The registered schemes, or null if the host exposes none, which resolves to no registered schemes. + /// + public AuthenticationSchemeResolver( + FusionAuthorizationOptions options, + IAuthenticationSchemeLookup? lookup) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _lookup = lookup; + } + + /// + /// Gets a value indicating whether the host exposes its registered authentication schemes. + /// + public bool HasLookup => _lookup is not null; + + /// + /// Gets the names of the registered authentication schemes. + /// + /// + /// The token that signals that the operation was aborted. + /// + public ValueTask> GetRegisteredAsync(CancellationToken cancellationToken) + => _lookup is null + ? new ValueTask>([]) + : _lookup.GetSchemeNamesAsync(cancellationToken); + + /// + /// Gets the value of the WWW-Authenticate header that advertises the HTTP authentication schemes + /// of the selected registrations, deduplicated and in ordinal order, or null if none contributes. + /// + /// + /// The token that signals that the operation was aborted. + /// + public async ValueTask GetChallengeAsync(CancellationToken cancellationToken) + { + if (_lookup is null) + { + return null; + } + + var registered = await GetRegisteredAsync(cancellationToken).ConfigureAwait(false); + var selected = _options.Schemes ?? registered; + var challenges = new SortedSet(StringComparer.Ordinal); + + foreach (var schemeName in selected) + { + if (!registered.Contains(schemeName)) + { + continue; + } + + var challenge = await _lookup.GetChallengeAsync(schemeName, cancellationToken).ConfigureAwait(false); + + if (challenge is not null || _options.SchemeChallenges.TryGetValue(schemeName, out challenge)) + { + challenges.Add(challenge); + } + } + + return challenges.Count == 0 ? null : string.Join(", ", challenges); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthorizationStartupValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthorizationStartupValidator.cs new file mode 100644 index 00000000000..43464d6d1bd --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthorizationStartupValidator.cs @@ -0,0 +1,120 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Types.Metadata; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// Fails the startup of a gateway whose authorization requirements cannot be enforced. +/// +internal static class AuthorizationStartupValidator +{ + /// + /// Validates the Schemes option, and for a schema that uses authorization also that a + /// usable authentication scheme exists and that every policy name resolves. + /// + /// + /// The authorization requirements the schema uses. + /// + /// + /// The authorization options. + /// + /// + /// The authentication schemes of the host. + /// + /// + /// The resolver that is asked for every policy name of the schema. + /// + /// + /// The token that signals that the operation was aborted. + /// + public static async ValueTask ValidateAsync( + FusionAuthorizationUsage? usage, + FusionAuthorizationOptions options, + AuthenticationSchemeResolver schemeResolver, + IPolicyResolver policyResolver, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(schemeResolver); + ArgumentNullException.ThrowIfNull(policyResolver); + + if (options.DisableAuthorizationValidation) + { + return; + } + + var registered = await ValidateConfigurationAsync(options, schemeResolver, cancellationToken) + .ConfigureAwait(false); + + if (usage is null) + { + return; + } + + if (usage.IsUsed) + { + ValidateUsage(options, schemeResolver, registered); + } + + var policyNames = usage.PolicyNames; + + for (var i = 0; i < policyNames.Length; i++) + { + if (policyResolver.Resolve(policyNames[i], DirectiveNames.Policy.Name) is null) + { + throw ThrowHelper.PolicyNotResolved(DirectiveNames.Policy.Name, policyNames[i]); + } + } + } + + private static async ValueTask> ValidateConfigurationAsync( + FusionAuthorizationOptions options, + AuthenticationSchemeResolver schemeResolver, + CancellationToken cancellationToken) + { + var registered = await schemeResolver.GetRegisteredAsync(cancellationToken).ConfigureAwait(false); + + if (options.Schemes is not { } listed) + { + return registered; + } + + if (listed.IsEmpty) + { + throw ThrowHelper.EmptyAuthenticationSchemes(); + } + + if (!schemeResolver.HasLookup) + { + throw ThrowHelper.AuthenticationSchemesWithoutLookup(); + } + + for (var i = 0; i < listed.Length; i++) + { + if (!registered.Contains(listed[i])) + { + throw ThrowHelper.AuthenticationSchemeNotRegistered(listed[i]); + } + } + + return registered; + } + + private static void ValidateUsage( + FusionAuthorizationOptions options, + AuthenticationSchemeResolver schemeResolver, + ImmutableArray registered) + { + if (!schemeResolver.HasLookup) + { + throw ThrowHelper.NoAuthenticationSchemeLookup(); + } + + if (options.Schemes is null && registered.IsEmpty) + { + throw ThrowHelper.NoAuthenticationSchemeRegistered(); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthorizationVariable.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthorizationVariable.cs new file mode 100644 index 00000000000..d6a92f81225 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/AuthorizationVariable.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Execution.Nodes; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// A synthetic Boolean variable of an operation plan that is true for a variable set +/// when the selections it gates are denied. +/// +public sealed class AuthorizationVariable +{ + internal AuthorizationVariable( + string name, + ImmutableArray selections, + ImmutableArray operands) + { + Name = name; + Selections = selections; + Operands = operands; + } + + /// + /// Gets the name of the variable. + /// + public string Name { get; } + + /// + /// Gets the protected selections the variable gates. The variable is true when any of + /// them is denied. It is empty for variables that combine other variables. + /// + public ImmutableArray Selections { get; } + + /// + /// Gets the variables the variable combines. The variable is true when all of them are + /// true. It is empty for variables that gate selections. + /// + public ImmutableArray Operands { get; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/BuiltInPolicyProvider.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/BuiltInPolicyProvider.cs new file mode 100644 index 00000000000..232a1419f74 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/BuiltInPolicyProvider.cs @@ -0,0 +1,46 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// Provides the policies of the @authenticated and @requiresScopes directives. +/// It ignores the policy name and answers nothing for other directives. +/// +public sealed class BuiltInPolicyProvider : IPolicyProvider +{ + private readonly RequiresScopesPolicy _requiresScopes; + + /// + /// Initializes a new instance of that reads scopes + /// from the space delimited scope claim. + /// + public BuiltInPolicyProvider() : this(new RequiresScopesPolicy()) + { + } + + /// + /// Initializes a new instance of . + /// + /// + /// The policy that evaluates @requiresScopes. + /// + public BuiltInPolicyProvider(RequiresScopesPolicy requiresScopes) + { + ArgumentNullException.ThrowIfNull(requiresScopes); + _requiresScopes = requiresScopes; + } + + /// + public IPolicy? GetPolicy(string policyName, string directiveName) + { + ArgumentNullException.ThrowIfNull(policyName); + ArgumentNullException.ThrowIfNull(directiveName); + + return directiveName switch + { + DirectiveNames.Authenticated.Name => AuthenticatedPolicy.Instance, + DirectiveNames.RequiresScopes.Name => _requiresScopes, + _ => null + }; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/DenyHandling.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/DenyHandling.cs new file mode 100644 index 00000000000..e971b7ea781 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/DenyHandling.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Specifies how a denied selection is reported to the client. +/// +public enum DenyHandling +{ + /// + /// A denied selection resolves to null without an error. + /// + Null, + + /// + /// A denied selection resolves to null and produces a field error. + /// + Error +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/FusionAuthorizationOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/FusionAuthorizationOptions.cs new file mode 100644 index 00000000000..419464f1317 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/FusionAuthorizationOptions.cs @@ -0,0 +1,159 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Execution; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// Represents the authorization options of the Fusion gateway. +/// +public sealed class FusionAuthorizationOptions +{ + private bool _isReadOnly; + + /// + /// Gets or sets how a denied selection is reported. + /// by default. + /// + public DenyHandling DenyHandling + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } = DenyHandling.Null; + + /// + /// Gets or sets which denials reject the whole request. + /// by default. + /// + public RejectRequestOn RejectRequestOn + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } = RejectRequestOn.Off; + + /// + /// Gets or sets the names of the authentication schemes the gateway authenticates against, or + /// null (the default) for every registered scheme. An empty list is a configuration error, + /// and every listed name must be a registered scheme. + /// + public ImmutableArray? Schemes + { + get; + set + { + ExpectMutableOptions(); + + field = value is { IsDefault: true } ? null : value; + } + } + + /// + /// Gets or sets the WWW-Authenticate challenge per authentication scheme name for handlers whose + /// challenge is not fixed by protocol. A scheme without an entry contributes no challenge. + /// + public ImmutableDictionary SchemeChallenges + { + get; + set + { + ExpectMutableOptions(); + ArgumentNullException.ThrowIfNull(value); + + foreach (var entry in value) + { + if (string.IsNullOrWhiteSpace(entry.Value)) + { + throw ThrowHelper.SchemeChallengeEmpty(entry.Key, nameof(value)); + } + } + + field = value; + } + } +#if NET10_0_OR_GREATER + = []; +#else + = ImmutableDictionary.Empty; +#endif + + /// + /// Gets or sets the type of the claim that holds the scopes of the principal. + /// scope by default. + /// + public string ScopeClaimName + { + get; + set + { + ExpectMutableOptions(); + ArgumentException.ThrowIfNullOrEmpty(value); + + field = value; + } + } = RequiresScopesPolicy.DefaultScopeClaimType; + + /// + /// Gets or sets how the scopes are stored in the scope claim. + /// by default. + /// + public ScopeClaimFormat ScopeClaimFormat + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } = ScopeClaimFormat.SpaceSeparated; + + /// + /// Gets or sets whether denial errors carry the directive, policy name and required scopes. + /// false by default. + /// + public bool EnableAttribution + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } + + /// + /// Gets or sets whether the startup validation of the authentication schemes and policy names + /// is skipped. false by default. + /// + public bool DisableAuthorizationValidation + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } + + internal void MakeReadOnly() + => _isReadOnly = true; + + private void ExpectMutableOptions() + { + if (_isReadOnly) + { + throw ThrowHelper.AuthorizationOptionsAreReadOnly(); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IAuthenticationSchemeLookup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IAuthenticationSchemeLookup.cs new file mode 100644 index 00000000000..a5552e183e5 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IAuthenticationSchemeLookup.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// Provides the authentication schemes the host has registered and the challenges their handlers advertise. +/// +internal interface IAuthenticationSchemeLookup +{ + /// + /// Gets the names of all registered authentication schemes. + /// + /// + /// The token that signals that the operation was aborted. + /// + ValueTask> GetSchemeNamesAsync(CancellationToken cancellationToken); + + /// + /// Gets the HTTP authentication scheme that the handler of the named registration advertises by protocol, + /// or null if the handler does not fix one. + /// + /// + /// The name of a registered authentication scheme. + /// + /// + /// The token that signals that the operation was aborted. + /// + ValueTask GetChallengeAsync(string schemeName, CancellationToken cancellationToken); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicy.cs new file mode 100644 index 00000000000..1fbfd06e710 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicy.cs @@ -0,0 +1,25 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Evaluates a single policy for all of its occurrences in an operation. +/// +/// +/// Instances are shared across requests. Per-request state must only be read from the +/// . +/// +public interface IPolicy +{ + /// + /// Answers the entries of the context through its Allow and Deny methods. + /// Entries that are not answered stay . + /// + /// + /// The evaluation context. + /// + /// + /// The token that signals that the request was aborted. + /// + ValueTask EvaluateAsync( + PolicyEvaluationContext context, + CancellationToken cancellationToken); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicyProvider.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicyProvider.cs new file mode 100644 index 00000000000..d6f3047dc07 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicyProvider.cs @@ -0,0 +1,21 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Provides the for a policy name. +/// +public interface IPolicyProvider +{ + /// + /// Gets the policy for the given policy name and directive. + /// + /// + /// The opaque policy name. + /// + /// + /// The name of the directive that references the policy. + /// + /// + /// The policy, or null if this provider does not know the policy. + /// + IPolicy? GetPolicy(string policyName, string directiveName); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicyResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicyResolver.cs new file mode 100644 index 00000000000..cbab33ae94f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/IPolicyResolver.cs @@ -0,0 +1,21 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Resolves the for a policy name across all registered providers. +/// +public interface IPolicyResolver +{ + /// + /// Resolves the policy for the given policy name and directive. + /// + /// + /// The opaque policy name. + /// + /// + /// The name of the directive that references the policy. + /// + /// + /// The policy of the first provider that knows it, or null if no provider does. + /// + IPolicy? Resolve(string policyName, string directiveName); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicy.cs new file mode 100644 index 00000000000..e8ce5a39763 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicy.cs @@ -0,0 +1,43 @@ +namespace HotChocolate.Fusion.Authorization.InMemory; + +internal sealed class InMemoryPolicy : IPolicy +{ + private readonly string _policyName; + private readonly Func _evaluate; + private readonly InMemoryPolicyRecorder _recorder; + + public InMemoryPolicy( + string policyName, + Func evaluate, + InMemoryPolicyRecorder recorder) + { + _policyName = policyName; + _evaluate = evaluate; + _recorder = recorder; + } + + public ValueTask EvaluateAsync( + PolicyEvaluationContext context, + CancellationToken cancellationToken) + { + foreach (ref readonly var entry in context.Entries) + { + _recorder.Record(_policyName, in entry); + + var verdict = _evaluate(context, entry); + + switch (verdict.Outcome) + { + case PolicyOutcome.Allowed: + context.Allow(in entry); + break; + + case PolicyOutcome.Denied: + context.Deny(in entry, verdict.Reason); + break; + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyBuilder.cs new file mode 100644 index 00000000000..e708a0d5bed --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyBuilder.cs @@ -0,0 +1,89 @@ +namespace HotChocolate.Fusion.Authorization.InMemory; + +/// +/// Configures the verdicts of the in-memory policies. +/// +public sealed class InMemoryPolicyBuilder +{ + private readonly Dictionary> _evaluators = + new(StringComparer.Ordinal); + + /// + /// Allows every entry of the policy. + /// + /// + /// The policy name. + /// + /// + /// The builder. + /// + public InMemoryPolicyBuilder Allow(string policyName) + { + ArgumentException.ThrowIfNullOrEmpty(policyName); + _evaluators[policyName] = static (_, _) => new PolicyVerdict(PolicyOutcome.Allowed, null, null); + return this; + } + + /// + /// Denies every entry of the policy. + /// + /// + /// The policy name. + /// + /// + /// The reason that is recorded for auditing. + /// + /// + /// The builder. + /// + public InMemoryPolicyBuilder Deny(string policyName, string? reason = null) + { + ArgumentException.ThrowIfNullOrEmpty(policyName); + _evaluators[policyName] = (_, _) => new PolicyVerdict(PolicyOutcome.Denied, reason, null); + return this; + } + + /// + /// Registers the policy so that it resolves but leaves every entry unanswered. + /// + /// + /// The policy name. + /// + /// + /// The builder. + /// + public InMemoryPolicyBuilder Unanswered(string policyName) + { + ArgumentException.ThrowIfNullOrEmpty(policyName); + _evaluators[policyName] = static (_, _) => new PolicyVerdict(PolicyOutcome.Unanswered, null, null); + return this; + } + + /// + /// Allows each entry of the policy for which the delegate returns true and denies the others. + /// + /// + /// The policy name. + /// + /// + /// The delegate that decides an entry. + /// + /// + /// The builder. + /// + public InMemoryPolicyBuilder Evaluate( + string policyName, + Func evaluate) + { + ArgumentException.ThrowIfNullOrEmpty(policyName); + ArgumentNullException.ThrowIfNull(evaluate); + + _evaluators[policyName] = (context, entry) => evaluate(context, entry) + ? new PolicyVerdict(PolicyOutcome.Allowed, null, null) + : new PolicyVerdict(PolicyOutcome.Denied, null, null); + return this; + } + + internal InMemoryPolicyProvider Build(InMemoryPolicyRecorder recorder) + => new(new(_evaluators, StringComparer.Ordinal), recorder); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyProvider.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyProvider.cs new file mode 100644 index 00000000000..b2d4f01962a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyProvider.cs @@ -0,0 +1,37 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Authorization.InMemory; + +/// +/// Provides the configured in-memory policies for the @policy directive. Policy names +/// that were not configured return null. +/// +public sealed class InMemoryPolicyProvider : IPolicyProvider +{ + private readonly Dictionary> _evaluators; + private readonly InMemoryPolicyRecorder _recorder; + + internal InMemoryPolicyProvider( + Dictionary> evaluators, + InMemoryPolicyRecorder recorder) + { + _evaluators = evaluators; + _recorder = recorder; + } + + /// + public IPolicy? GetPolicy(string policyName, string directiveName) + { + ArgumentNullException.ThrowIfNull(policyName); + ArgumentNullException.ThrowIfNull(directiveName); + + if (directiveName is not DirectiveNames.Policy.Name) + { + return null; + } + + return _evaluators.TryGetValue(policyName, out var evaluate) + ? new InMemoryPolicy(policyName, evaluate, _recorder) + : null; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyRecord.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyRecord.cs new file mode 100644 index 00000000000..5e1626e3052 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyRecord.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Authorization.InMemory; + +/// +/// An entry that an in-memory policy was asked about. +/// +/// +/// The name of the policy. +/// +/// +/// The entry that was evaluated. +/// +public readonly record struct InMemoryPolicyRecord( + string PolicyName, + PolicyEvaluationEntry Entry); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyRecorder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyRecorder.cs new file mode 100644 index 00000000000..f522102dc89 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/InMemory/InMemoryPolicyRecorder.cs @@ -0,0 +1,43 @@ +namespace HotChocolate.Fusion.Authorization.InMemory; + +/// +/// Records every entry the in-memory policies were asked about, in evaluation order. +/// +public sealed class InMemoryPolicyRecorder +{ + private readonly object _sync = new(); + private readonly List _records = []; + + /// + /// Gets a snapshot of the recorded entries in evaluation order. + /// + public IReadOnlyList Records + { + get + { + lock (_sync) + { + return [.. _records]; + } + } + } + + /// + /// Removes all recorded entries. + /// + public void Clear() + { + lock (_sync) + { + _records.Clear(); + } + } + + internal void Record(string policyName, in PolicyEvaluationEntry entry) + { + lock (_sync) + { + _records.Add(new InMemoryPolicyRecord(policyName, entry)); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/OperationAuthorization.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/OperationAuthorization.cs new file mode 100644 index 00000000000..74a22e92ee5 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/OperationAuthorization.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// The authorization requirements of the selections of an operation and the synthetic variables +/// that skip denied selections in source schema requests. +/// +public sealed class OperationAuthorization +{ + internal OperationAuthorization( + ImmutableArray descriptors, + ImmutableArray variables) + { + Descriptors = descriptors; + Variables = variables; + } + + /// + /// Gets one descriptor for every directive occurrence of every protected selection. + /// + public ImmutableArray Descriptors { get; } + + /// + /// Gets the synthetic variables in the order they were allocated. + /// + public ImmutableArray Variables { get; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyDescriptor.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyDescriptor.cs new file mode 100644 index 00000000000..71dd338cebb --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyDescriptor.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; +using HotChocolate.Execution; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// Describes one occurrence of an authorization directive on a selection together with the +/// policy that evaluates it. +/// +public sealed class PolicyDescriptor +{ + /// + /// Initializes a new instance of . + /// + /// + /// The name of the directive, see . + /// + /// + /// The opaque policy name, or null for directives without one. + /// + /// + /// The required scopes as OR-of-AND groups, or a default or empty array for directives + /// without scopes. + /// + /// + /// The selection the directive applies to. + /// + /// + /// The policy that evaluates the directive. + /// + public PolicyDescriptor( + string directiveName, + string? policyName, + ImmutableArray> scopes, + ISelection selection, + IPolicy policy) + { + ArgumentException.ThrowIfNullOrEmpty(directiveName); + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(policy); + + DirectiveName = directiveName; + PolicyName = policyName; + Scopes = scopes.IsDefault ? [] : scopes; + Selection = selection; + Policy = policy; + } + + /// + /// Gets the name of the directive. + /// + public string DirectiveName { get; } + + /// + /// Gets the opaque policy name, or null for directives without one. + /// + public string? PolicyName { get; } + + /// + /// Gets the required scopes as OR-of-AND groups, which is empty when the descriptor has no + /// scope requirement. + /// + public ImmutableArray> Scopes { get; } + + /// + /// Gets the selection the directive applies to. + /// + public ISelection Selection { get; } + + /// + /// Gets the policy that evaluates the directive. + /// + public IPolicy Policy { get; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationContext.cs new file mode 100644 index 00000000000..d21529191cd --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationContext.cs @@ -0,0 +1,201 @@ +using System.Collections.Immutable; +using System.Security.Claims; +using HotChocolate.Features; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Execution.Nodes; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// The input and the answer sheet of one policy evaluation for one variable set. +/// +public sealed class PolicyEvaluationContext +{ + private readonly PolicyEvaluationEntry[] _entries; + private readonly PolicyVerdict[] _verdicts; + + /// + /// Initializes a new instance of . + /// + /// + /// The principal of the request. An unauthenticated principal represents an anonymous caller. + /// + /// + /// The features of the request. + /// + /// + /// The request scoped service provider. + /// + /// + /// The plan of the operation being authorized. + /// + /// + /// The index of the variable set within the request. + /// + /// + /// The occurrences of the policy that are evaluated. + /// + public PolicyEvaluationContext( + ClaimsPrincipal user, + IFeatureCollection features, + IServiceProvider requestServices, + OperationPlan plan, + int requestIndex, + ReadOnlySpan entries) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(requestServices); + ArgumentNullException.ThrowIfNull(plan); + + User = user; + Features = features; + RequestServices = requestServices; + Plan = plan; + RequestIndex = requestIndex; + + _entries = new PolicyEvaluationEntry[entries.Length]; + _verdicts = new PolicyVerdict[entries.Length]; + + for (var i = 0; i < entries.Length; i++) + { + _entries[i] = entries[i] with { Slot = i + 1 }; + } + } + + /// + /// Gets the principal of the request. + /// + public ClaimsPrincipal User { get; } + + /// + /// Gets the features of the request. + /// + public IFeatureCollection Features { get; } + + /// + /// Gets the request scoped service provider. + /// + public IServiceProvider RequestServices { get; } + + /// + /// Gets the plan of the operation being authorized. + /// + public OperationPlan Plan { get; } + + /// + /// Gets the index of the variable set within the request. + /// + public int RequestIndex { get; } + + /// + /// Gets the occurrences of the policy that are evaluated. + /// + public ReadOnlySpan Entries => _entries; + + /// + /// Gets the verdicts in the order of . + /// + public ReadOnlySpan Verdicts => _verdicts; + + /// + /// Allows the entry unless it was already denied. + /// + /// + /// An entry of . + /// + public void Allow(in PolicyEvaluationEntry entry) + => Record(entry, PolicyOutcome.Allowed, null, null); + + /// + /// Allows the entry unless it was already denied. + /// + /// + /// An entry of . + /// + /// + /// Key/value pairs that are only meant for auditing. + /// + public void Allow(in PolicyEvaluationEntry entry, ImmutableDictionary auditData) + { + ArgumentNullException.ThrowIfNull(auditData); + Record(entry, PolicyOutcome.Allowed, null, auditData); + } + + /// + /// Denies the entry. A denial cannot be overridden by a later answer. + /// + /// + /// An entry of . + /// + /// + /// An optional reason that is only meant for auditing. + /// + public void Deny(in PolicyEvaluationEntry entry, string? reason = null) + => Record(entry, PolicyOutcome.Denied, reason, null); + + /// + /// Denies the entry. A denial cannot be overridden by a later answer. + /// + /// + /// An entry of . + /// + /// + /// An optional reason that is only meant for auditing. + /// + /// + /// Key/value pairs that are only meant for auditing. + /// + public void Deny( + in PolicyEvaluationEntry entry, + string? reason, + ImmutableDictionary auditData) + { + ArgumentNullException.ThrowIfNull(auditData); + Record(entry, PolicyOutcome.Denied, reason, auditData); + } + + /// + /// Gets the verdict recorded for the entry. + /// + /// + /// An entry of . + /// + public PolicyVerdict GetVerdict(in PolicyEvaluationEntry entry) + => _verdicts[GetIndex(entry)]; + + private void Record( + in PolicyEvaluationEntry entry, + PolicyOutcome outcome, + string? reason, + ImmutableDictionary? auditData) + { + var index = GetIndex(entry); + + if (_verdicts[index].Outcome is PolicyOutcome.Denied) + { + return; + } + + if (outcome is PolicyOutcome.Allowed && _verdicts[index].Outcome is PolicyOutcome.Allowed) + { + return; + } + + _verdicts[index] = new PolicyVerdict(outcome, reason, auditData); + } + + private int GetIndex(in PolicyEvaluationEntry entry) + { + var index = entry.Slot - 1; + + if (index < 0 + || index >= _entries.Length + || !ReferenceEquals(_entries[index].Descriptor, entry.Descriptor)) + { + throw ThrowHelper.PolicyEntryNotPartOfContext(); + } + + return index; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationEntry.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationEntry.cs new file mode 100644 index 00000000000..2f6654a1c9c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationEntry.cs @@ -0,0 +1,24 @@ +using HotChocolate.Execution; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// One occurrence of a policy within a variable set, with the coerced arguments of the selection. +/// +/// +/// The descriptor of the occurrence. +/// +/// +/// The coerced arguments of the selection for the variable set. +/// +public readonly record struct PolicyEvaluationEntry( + PolicyDescriptor Descriptor, + IReadOnlyDictionary Arguments) +{ + /// + /// Gets the selection the occurrence applies to. + /// + public ISelection Selection => Descriptor.Selection; + + internal int Slot { get; init; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationOrder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationOrder.cs new file mode 100644 index 00000000000..bd2c793f114 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyEvaluationOrder.cs @@ -0,0 +1,25 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Authorization; + +internal static class PolicyEvaluationOrder +{ + /// + /// Gets the position of the directive in the evaluation order: authenticated, + /// then scopes, then named policies. + /// + /// + /// The name of the directive. + /// + public static int Get(string directiveName) + { + ArgumentNullException.ThrowIfNull(directiveName); + + return directiveName switch + { + DirectiveNames.Authenticated.Name => 0, + DirectiveNames.RequiresScopes.Name => 1, + _ => 2 + }; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyOutcome.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyOutcome.cs new file mode 100644 index 00000000000..426cac6130c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyOutcome.cs @@ -0,0 +1,22 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// The decision a policy reached for a single . +/// +public enum PolicyOutcome +{ + /// + /// The policy did not decide the entry. The entry is denied for the client. + /// + Unanswered = 0, + + /// + /// The policy allowed the entry. + /// + Allowed = 1, + + /// + /// The policy denied the entry. + /// + Denied = 2 +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyResolver.cs new file mode 100644 index 00000000000..75e22918cc1 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyResolver.cs @@ -0,0 +1,48 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Resolves policies through the built-in provider first and then through the registered +/// providers in registration order. The first provider that knows the policy wins. +/// +public sealed class PolicyResolver : IPolicyResolver +{ + private readonly IPolicyProvider[] _providers; + + /// + /// Initializes a new instance of . + /// + /// + /// The provider that is always asked first. + /// + /// + /// The providers in the order they are asked after the built-in provider. + /// + public PolicyResolver( + IPolicyProvider builtInProvider, + IEnumerable providers) + { + ArgumentNullException.ThrowIfNull(builtInProvider); + ArgumentNullException.ThrowIfNull(providers); + + _providers = [builtInProvider, .. providers]; + } + + /// + public IPolicy? Resolve(string policyName, string directiveName) + { + ArgumentNullException.ThrowIfNull(policyName); + ArgumentNullException.ThrowIfNull(directiveName); + + foreach (var provider in _providers) + { + var policy = provider.GetPolicy(policyName, directiveName); + + if (policy is not null) + { + return policy; + } + } + + return null; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyVerdict.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyVerdict.cs new file mode 100644 index 00000000000..cfab02744ae --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/PolicyVerdict.cs @@ -0,0 +1,20 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// The result a policy recorded for a single . +/// +/// +/// The decision of the policy. +/// +/// +/// An optional reason for the decision that is only meant for auditing. +/// +/// +/// Optional key/value pairs that are only meant for auditing. +/// +public readonly record struct PolicyVerdict( + PolicyOutcome Outcome, + string? Reason, + ImmutableDictionary? AuditData); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/RejectRequestOn.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/RejectRequestOn.cs new file mode 100644 index 00000000000..fb5c4ea4bde --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/RejectRequestOn.cs @@ -0,0 +1,22 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Specifies which denials reject the whole request. Each level includes the levels before it. +/// +public enum RejectRequestOn +{ + /// + /// A denial never rejects the request. + /// + Off, + + /// + /// A denial because the principal is not authenticated rejects the request. + /// + OnUnauthenticated, + + /// + /// Any denial rejects the request. + /// + OnUnauthorized +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/RequiresScopesPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/RequiresScopesPolicy.cs new file mode 100644 index 00000000000..ea04675c34b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/RequiresScopesPolicy.cs @@ -0,0 +1,120 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +/// +/// The built-in policy of the @requiresScopes directive. An entry is allowed when the +/// principal is authenticated and holds all scopes of at least one of the scope groups of the entry. +/// +public sealed class RequiresScopesPolicy : IPolicy +{ + internal const string DefaultScopeClaimType = "scope"; + + private readonly string _scopeClaimType; + private readonly ScopeClaimFormat _scopeClaimFormat; + + /// + /// Initializes a new instance of that reads the + /// space delimited scope claim. + /// + public RequiresScopesPolicy() : this(DefaultScopeClaimType, ScopeClaimFormat.SpaceSeparated) + { + } + + /// + /// Initializes a new instance of . + /// + /// + /// The claim type whose values are the scopes of the principal. + /// + /// + /// How the scopes are stored in the values of the claim. + /// + public RequiresScopesPolicy(string scopeClaimType, ScopeClaimFormat scopeClaimFormat) + { + ArgumentException.ThrowIfNullOrEmpty(scopeClaimType); + _scopeClaimType = scopeClaimType; + _scopeClaimFormat = scopeClaimFormat; + } + + /// + public ValueTask EvaluateAsync( + PolicyEvaluationContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.User.Identity?.IsAuthenticated is not true) + { + foreach (ref readonly var entry in context.Entries) + { + context.Deny(in entry); + } + + return ValueTask.CompletedTask; + } + + var granted = ReadScopes(context); + + foreach (ref readonly var entry in context.Entries) + { + if (IsSatisfied(entry.Descriptor.Scopes, granted)) + { + context.Allow(in entry); + } + else + { + context.Deny(in entry); + } + } + + return ValueTask.CompletedTask; + } + + private HashSet ReadScopes(PolicyEvaluationContext context) + { + var granted = new HashSet(StringComparer.Ordinal); + + foreach (var claim in context.User.FindAll(_scopeClaimType)) + { + if (_scopeClaimFormat is ScopeClaimFormat.Array) + { + granted.Add(claim.Value); + continue; + } + + foreach (var scope in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + granted.Add(scope); + } + } + + return granted; + } + + private static bool IsSatisfied( + ImmutableArray> groups, + HashSet granted) + { + foreach (var group in groups) + { + var satisfied = true; + + foreach (var scope in group) + { + if (!granted.Contains(scope)) + { + satisfied = false; + break; + } + } + + if (satisfied) + { + return true; + } + } + + return false; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/ScopeClaimFormat.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/ScopeClaimFormat.cs new file mode 100644 index 00000000000..b633c4f6cc0 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/ScopeClaimFormat.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Authorization; + +/// +/// Specifies how the scopes of a principal are stored in its scope claim. +/// +public enum ScopeClaimFormat +{ + /// + /// Every claim value holds space delimited scopes, as defined by OAuth 2.0. + /// + SpaceSeparated, + + /// + /// Every claim value holds exactly one scope. + /// + Array +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/UnresolvedPolicy.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/UnresolvedPolicy.cs new file mode 100644 index 00000000000..b70354a8274 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Authorization/UnresolvedPolicy.cs @@ -0,0 +1,11 @@ +using HotChocolate.Fusion.Execution; + +namespace HotChocolate.Fusion.Authorization; + +internal sealed class UnresolvedPolicy(string directiveName, string? policyName) : IPolicy +{ + public ValueTask EvaluateAsync( + PolicyEvaluationContext context, + CancellationToken cancellationToken) + => throw ThrowHelper.PolicyNotResolved(directiveName, policyName); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs index 47a562a83a6..865c312f640 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs @@ -1,5 +1,6 @@ using HotChocolate.Execution; using HotChocolate.Features; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution; using HotChocolate.Fusion.Execution.Clients; using HotChocolate.Fusion.Planning; @@ -22,6 +23,8 @@ public sealed class FusionGatewaySetup public List> ParserOptionsModifiers { get; } = []; + public List> AuthorizationOptionsModifiers { get; } = []; + public List> SchemaServiceModifiers { get; } = []; public List>> PipelineModifiers { get; } = []; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Options.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Options.cs index a907dfc039b..7926c38b02a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Options.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Options.cs @@ -1,3 +1,4 @@ +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Configuration; using HotChocolate.Fusion.Execution; using HotChocolate.Fusion.Planning; @@ -84,4 +85,23 @@ public static IFusionGatewayBuilder ModifyCostOptions( builder, options => options.CostOptionsModifiers.Add(configure)); } + + /// + /// Registers a callback to modify the + /// (deny handling, request rejection, authentication schemes, scope claim, etc.). + /// + /// The gateway builder. + /// A delegate that configures the authorization options. + /// The for chaining. + public static IFusionGatewayBuilder ModifyAuthorizationOptions( + this IFusionGatewayBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + return FusionSetupUtilities.Configure( + builder, + options => options.AuthorizationOptionsModifiers.Add(configure)); + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Policies.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Policies.cs new file mode 100644 index 00000000000..c501bf9b01f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Policies.cs @@ -0,0 +1,42 @@ +using HotChocolate.Fusion.Authorization; +using HotChocolate.Fusion.Authorization.InMemory; +using HotChocolate.Fusion.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +public static partial class CoreFusionGatewayBuilderExtensions +{ + /// + /// Adds in-memory policies whose verdicts are configured in code. Every entry they are + /// asked about is available from the of the application services. + /// + /// + /// The fusion gateway builder. + /// + /// + /// A delegate that configures the verdicts. + /// + /// + /// The fusion gateway builder. + /// + public static IFusionGatewayBuilder AddInMemoryPolicies( + this IFusionGatewayBuilder builder, + Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + var policies = new InMemoryPolicyBuilder(); + configure(policies); + + builder.Services.TryAddSingleton(); + + return builder.ConfigureSchemaServices( + (sp, sc) => + { + sc.AddSingleton( + policies.Build(sp.GetRequiredService())); + }); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs index a2852b47781..5fdc6bb9211 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs @@ -13,6 +13,7 @@ using HotChocolate.Execution.Instrumentation; using HotChocolate.Execution.Pipeline; using HotChocolate.Features; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Configuration; using HotChocolate.Fusion.Configuration.Parsers; using HotChocolate.Fusion.Diagnostics; @@ -24,6 +25,7 @@ using HotChocolate.Fusion.Planning; using HotChocolate.Fusion.Types; using HotChocolate.Fusion.Types.Completion; +using HotChocolate.Fusion.Types.Metadata; using HotChocolate.Language; using HotChocolate.Utilities; using HotChocolate.Validation; @@ -160,6 +162,7 @@ await GetSchemaDocumentAsync( var executor = CreateRequestExecutor(schemaName, configuration); + await ValidateAuthorizationAsync(executor, cancellationToken).ConfigureAwait(false); await WarmupExecutorAsync(executor, true, cancellationToken).ConfigureAwait(false); return new RequestExecutorRegistration( @@ -187,6 +190,7 @@ private FusionRequestExecutor CreateRequestExecutor( var requestOptions = CreateRequestOptions(setup); var plannerOptions = CreatePlannerOptions(setup, options); var costOptions = CreateCostOptions(setup); + var authorizationOptions = CreateAuthorizationOptions(setup); var parserOptions = CreateParserOptions(setup); var features = CreateSchemaFeatures( setup, @@ -200,7 +204,8 @@ private FusionRequestExecutor CreateRequestExecutor( options, requestOptions, plannerOptions, - costOptions); + costOptions, + authorizationOptions); var schema = CreateSchema(schemaName, configuration.Schema, schemaServices, features); _ = schemaServices.GetRequiredService(); @@ -214,6 +219,20 @@ private FusionRequestExecutor CreateRequestExecutor( return executor; } + private static ValueTask ValidateAuthorizationAsync( + FusionRequestExecutor executor, + CancellationToken cancellationToken) + { + var services = executor.Schema.Services; + + return AuthorizationStartupValidator.ValidateAsync( + executor.Schema.Features.Get(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + cancellationToken); + } + private static async Task WarmupExecutorAsync( IRequestExecutor executor, bool isInitialCreation, @@ -310,6 +329,20 @@ private static FusionCostOptions CreateCostOptions(FusionGatewaySetup setup) return costOptions; } + private static FusionAuthorizationOptions CreateAuthorizationOptions(FusionGatewaySetup setup) + { + var authorizationOptions = new FusionAuthorizationOptions(); + + foreach (var configure in setup.AuthorizationOptionsModifiers) + { + configure.Invoke(authorizationOptions); + } + + authorizationOptions.MakeReadOnly(); + + return authorizationOptions; + } + private static ParserOptions CreateParserOptions(FusionGatewaySetup setup) { var options = new FusionParserOptions(); @@ -394,7 +427,8 @@ private ServiceProvider CreateSchemaServices( FusionOptions options, FusionRequestOptions requestOptions, OperationPlannerOptions plannerOptions, - FusionCostOptions costOptions) + FusionCostOptions costOptions, + FusionAuthorizationOptions authorizationOptions) { var schemaServices = new ServiceCollection(); @@ -404,7 +438,8 @@ private ServiceProvider CreateSchemaServices( schemaServices, options, requestOptions, - costOptions); + costOptions, + authorizationOptions); AddOperationPlanner(schemaServices, plannerOptions); AddParserServices(schemaServices); AddDocumentValidator(setup, schemaServices, options); @@ -424,7 +459,8 @@ private void AddCoreServices( IServiceCollection services, FusionOptions options, FusionRequestOptions requestOptions, - FusionCostOptions costOptions) + FusionCostOptions costOptions, + FusionAuthorizationOptions authorizationOptions) { services.AddSingleton( new RootServiceProviderAccessor(_applicationServices)); @@ -451,6 +487,23 @@ private void AddCoreServices( services.AddSingleton(requestOptions); services.AddSingleton(requestOptions.PersistedOperations); services.AddSingleton(costOptions); + services.AddSingleton(authorizationOptions); + services.AddSingleton( + static sp => new AuthenticationSchemeResolver( + sp.GetRequiredService(), + sp.GetService())); + services.TryAddSingleton( + static sp => + { + var authorization = sp.GetRequiredService(); + + return new PolicyResolver( + new BuiltInPolicyProvider( + new RequiresScopesPolicy( + authorization.ScopeClaimName, + authorization.ScopeClaimFormat)), + sp.GetServices()); + }); services.AddSingleton( static sp => { @@ -550,7 +603,8 @@ private static void AddOperationPlanner( static sp => new OperationPlanner( sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); } private static void AddParserServices(IServiceCollection services) @@ -759,6 +813,7 @@ private async Task WaitForUpdatesAsync() var previousConfiguration = _currentConfiguration; var nextExecutor = _manager.CreateRequestExecutor(Executor.Schema.Name, configuration); + await ValidateAuthorizationAsync(nextExecutor, _cancellationToken).ConfigureAwait(false); await WarmupExecutorAsync(nextExecutor, false, _cancellationToken).ConfigureAwait(false); Executor = nextExecutor; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs index c732a4d713e..fb5f8928860 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Operation.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using HotChocolate.Execution; using HotChocolate.Features; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Types; using HotChocolate.Language; using HotChocolate.Types; @@ -24,6 +25,7 @@ public sealed class Operation : IOperation private readonly IReadOnlyDictionary _deliveryGroupByFragment; private readonly OperationFeatureCollection _features; private readonly bool _hasIncrementalParts; + private bool _authorizationSet; private object[] _elementsById; private int _lastId; @@ -128,6 +130,32 @@ ISelectionSet IOperation.RootSelectionSet public bool HasIncrementalParts => _hasIncrementalParts; + /// + /// Gets the authorization requirements of the operation, or null if no selection of + /// the operation is protected. + /// + public OperationAuthorization? Authorization { get; private set; } + + /// + /// Sets the authorization requirements of the operation. It can be called only once. + /// + /// + /// The authorization requirements, or null if no selection of the operation is protected. + /// + /// + /// The authorization was already set. + /// + internal void SetAuthorization(OperationAuthorization? authorization) + { + if (_authorizationSet) + { + throw ThrowHelper.OperationAuthorizationAlreadySet(); + } + + _authorizationSet = true; + Authorization = authorization; + } + /// /// Gets a value indicating whether this operation has more than 64 include /// conditions and therefore requires the wide include flag overloads. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs index 60c746523cb..3bc296f1e23 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs @@ -89,6 +89,11 @@ internal Selection( _deliveryGroups = deliveryGroups ?? s_emptyDeliveryGroups; _flags = isInternal ? Flags.Internal : Flags.None; + if (field is FusionOutputFieldDefinition { HasAuthorization: true }) + { + _flags |= Flags.HasAuthorization; + } + var type = field.Type; _type = type; @@ -178,6 +183,11 @@ internal Selection( /// internal bool IsNonNull => (_flags & Flags.NonNull) == Flags.NonNull; + /// + /// Gets a value indicating whether the field of this selection carries an authorization requirement. + /// + internal bool HasAuthorization => (_flags & Flags.HasAuthorization) == Flags.HasAuthorization; + /// /// Gets the selection's field type with its non-null wrapper removed. /// Equals when the field type is nullable. @@ -769,6 +779,7 @@ private enum Flags ValueTypeNamedType = 32, NonNullListElement = 64, RequiresWideIncludeFlags = 128, - RequiresWideDeferFlags = 256 + RequiresWideDeferFlags = 256, + HasAuthorization = 512 } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs index 5ee093cb665..3b5343e8d47 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ThrowHelper.cs @@ -7,6 +7,45 @@ namespace HotChocolate.Fusion.Execution; internal static class ThrowHelper { + public static InvalidOperationException PolicyEntryNotPartOfContext() + => new(FusionExecutionResources.PolicyEvaluationContext_EntryNotPartOfContext); + + public static InvalidOperationException AuthorizationOptionsAreReadOnly() + => new(FusionExecutionResources.FusionAuthorizationOptions_ReadOnly); + + public static ArgumentException SchemeChallengeEmpty(string schemeName, string paramName) + => new( + string.Format( + FusionExecutionResources.FusionAuthorizationOptions_SchemeChallengeEmpty, + schemeName), + paramName); + + public static InvalidOperationException AuthenticationSchemeNotRegistered(string schemeName) + => new(string.Format( + FusionExecutionResources.AuthorizationValidation_SchemeNotRegistered, + schemeName)); + + public static InvalidOperationException NoAuthenticationSchemeRegistered() + => new(FusionExecutionResources.AuthorizationValidation_NoSchemeRegistered); + + public static InvalidOperationException NoAuthenticationSchemeLookup() + => new(FusionExecutionResources.AuthorizationValidation_NoSchemeLookup); + + public static InvalidOperationException EmptyAuthenticationSchemes() + => new(FusionExecutionResources.AuthorizationValidation_SchemesEmpty); + + public static InvalidOperationException AuthenticationSchemesWithoutLookup() + => new(FusionExecutionResources.AuthorizationValidation_SchemesWithoutLookup); + + public static InvalidOperationException PolicyNotResolved(string directiveName, string? policyName) + => new(string.Format( + FusionExecutionResources.PolicyResolver_PolicyNotResolved, + policyName ?? string.Empty, + directiveName)); + + public static InvalidOperationException OperationAuthorizationAlreadySet() + => new(FusionExecutionResources.Operation_AuthorizationAlreadySet); + public static InvalidOperationException MissingBooleanVariable(string variableName) => new(string.Format( FusionExecutionResources.ExecutionNode_MissingBooleanVariable, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/AuthorizationMarker.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/AuthorizationMarker.cs new file mode 100644 index 00000000000..765d345112e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/AuthorizationMarker.cs @@ -0,0 +1,1096 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; +using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Rewriters; +using HotChocolate.Language; +using HotChocolate.Language.Visitors; +using HotChocolate.Types; +using OperationSelectionSet = HotChocolate.Fusion.Execution.Nodes.SelectionSet; + +namespace HotChocolate.Fusion.Planning; + +/// +/// Marks the deniable selections of the source schema requests of one operation with synthetic +/// @skip(if: $__fusion_auth_N) directives and promotes the variables of fully deniable +/// requests to execution node conditions. +/// +internal sealed class AuthorizationMarker +{ + private readonly FusionSchemaDefinition _schema; + private readonly AuthorizationPlanContext _context; + private readonly Operation _operation; + private readonly ImmutableArray _descriptors; + private readonly List _variables = []; + private readonly Dictionary _variablesByKey = new(StringComparer.Ordinal); + private readonly Dictionary _variablesByName = new(StringComparer.Ordinal); + private readonly Dictionary _requirementGates = []; + + private AuthorizationMarker( + FusionSchemaDefinition schema, + AuthorizationPlanContext context, + Operation operation, + ImmutableArray descriptors) + { + _schema = schema; + _context = context; + _operation = operation; + _descriptors = descriptors; + } + + /// + /// Gets a value indicating whether the marker allocated synthetic variables. + /// + public bool HasVariables => _variables.Count > 0; + + /// + /// Creates a marker for the operation, or returns null if no selection of the + /// operation is protected. + /// + public static AuthorizationMarker? TryCreate( + FusionSchemaDefinition schema, + AuthorizationPlanContext context, + Operation operation) + { + ArgumentNullException.ThrowIfNull(schema); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(operation); + + var candidates = new HashSet(ReferenceEqualityComparer.Instance); + + if (!FindProtectedFieldNames(operation.Definition.SelectionSet, context.ProtectedFieldNames, candidates)) + { + return null; + } + + var protectedSelections = new List(); + CollectProtectedSelections(operation, operation.RootSelectionSet, candidates, protectedSelections); + + if (protectedSelections.Count == 0) + { + return null; + } + + var descriptors = ImmutableArray.CreateBuilder(); + + foreach (var selection in protectedSelections) + { + AddDescriptors(context, selection, descriptors); + } + + return new AuthorizationMarker(schema, context, operation, descriptors.ToImmutable()); + } + + /// + /// Gets the variable definitions of the variables that source schema requests reference. + /// + public IEnumerable GetVariableDefinitions() + { + foreach (var variable in _variables) + { + if (!variable.Operands.IsEmpty) + { + continue; + } + + yield return new VariableDefinitionNode( + null, + new VariableNode(null, new NameNode(variable.Name)), + description: null, + new NonNullTypeNode(new NamedTypeNode(SpecScalarNames.Boolean.Name)), + defaultValue: null, + []); + } + } + + /// + /// Creates the authorization description of the operation. + /// + public OperationAuthorization CreateAuthorization() + => new(_descriptors, [.. _variables]); + + /// + /// Marks the operation steps and returns the updated steps. + /// + public ImmutableList Mark(ImmutableList steps) + { + ArgumentNullException.ThrowIfNull(steps); + + var analyses = new List(); + + for (var i = 0; i < steps.Count; i++) + { + if (steps[i] is OperationPlanStep operationStep) + { + analyses.Add(Analyze(operationStep, i)); + } + } + + ResolveRequirementGates(analyses); + + var updatedSteps = steps; + + foreach (var analysis in analyses) + { + var updated = Rewrite(analysis); + + if (!ReferenceEquals(updated, analysis.Step)) + { + updatedSteps = updatedSteps.SetItem(analysis.Index, updated); + } + } + + return updatedSteps; + } + + private static bool FindProtectedFieldNames( + SelectionSetNode selectionSet, + FrozenSet protectedFieldNames, + HashSet candidates) + { + var found = false; + + foreach (var selection in selectionSet.Selections) + { + if (selection is FieldNode field) + { + found |= protectedFieldNames.Contains(field.Name.Value); + + if (field.SelectionSet is not null) + { + found |= FindProtectedFieldNames(field.SelectionSet, protectedFieldNames, candidates); + } + } + else if (selection is InlineFragmentNode fragment) + { + found |= FindProtectedFieldNames(fragment.SelectionSet, protectedFieldNames, candidates); + } + } + + if (found) + { + candidates.Add(selectionSet); + } + + return found; + } + + private static void CollectProtectedSelections( + Operation operation, + OperationSelectionSet selectionSet, + HashSet candidates, + List protectedSelections) + { + foreach (var selection in selectionSet.Selections) + { + if (selection.HasAuthorization) + { + protectedSelections.Add(selection); + } + + if (selection.IsLeaf || !HasCandidateChildren(selection, candidates)) + { + continue; + } + + var childSets = new List(); + AddChildSelectionSets(operation, selection, childSets); + + foreach (var childSet in childSets) + { + CollectProtectedSelections(operation, childSet, candidates, protectedSelections); + } + } + } + + private static bool HasCandidateChildren(Selection selection, HashSet candidates) + { + foreach (var syntaxNode in selection.SyntaxNodes) + { + if (syntaxNode.Node.SelectionSet is { } selectionSet && candidates.Contains(selectionSet)) + { + return true; + } + } + + return false; + } + + private static void AddChildSelectionSets( + Operation operation, + Selection selection, + List childSets) + { + if (selection.IsLeaf) + { + return; + } + + if (selection.NamedType is IObjectTypeDefinition) + { + if (selection.GetSelectionSet() is { } childSet && !childSets.Contains(childSet)) + { + childSets.Add(childSet); + } + + return; + } + + foreach (var possibleType in operation.GetPossibleTypes(selection)) + { + if (possibleType is FusionComplexTypeDefinition complexType + && selection.GetSelectionSet(complexType) is { } childSet + && !childSets.Contains(childSet)) + { + childSets.Add(childSet); + } + } + } + + private static void AddDescriptors( + AuthorizationPlanContext context, + Selection selection, + ImmutableArray.Builder descriptors) + { + var authorization = ((FusionOutputFieldDefinition)selection.Field).Authorization!; + + if (authorization.Authenticated) + { + descriptors.Add( + new PolicyDescriptor( + DirectiveNames.Authenticated.Name, + policyName: null, + scopes: default, + selection, + context.GetPolicy(DirectiveNames.Authenticated.Name, null))); + } + + if (authorization.Scopes.Length > 0) + { + descriptors.Add( + new PolicyDescriptor( + DirectiveNames.RequiresScopes.Name, + policyName: null, + authorization.Scopes, + selection, + context.GetPolicy(DirectiveNames.RequiresScopes.Name, null))); + } + + var policyNames = new HashSet(StringComparer.Ordinal); + + foreach (var group in authorization.Policies) + { + foreach (var policyName in group) + { + if (policyNames.Add(policyName)) + { + descriptors.Add( + new PolicyDescriptor( + DirectiveNames.Policy.Name, + policyName, + scopes: default, + selection, + context.GetPolicy(DirectiveNames.Policy.Name, policyName))); + } + } + } + } + + private StepAnalysis Analyze(OperationPlanStep step, int index) + { + var contexts = ResolveContexts(step.Target); + + if (contexts is null + || !TryGetSelectionSet(step.Definition.SelectionSet, step.Source, 0, out var dataSet)) + { + return new StepAnalysis(step, index, null, null); + } + + var analysis = new StepAnalysis(step, index, dataSet, contexts); + AnalyzeSelectionSet(dataSet, contexts, analysis, true); + return analysis; + } + + private void AnalyzeSelectionSet( + SelectionSetNode selectionSet, + List contexts, + StepAnalysis analysis, + bool isRoot) + { + foreach (var selection in selectionSet.Selections) + { + if (selection is FieldNode field) + { + var selections = GetSelections(field, contexts); + var variable = GetVariable(selections); + + if (isRoot) + { + analysis.AddRootVariable(variable); + } + + if (field.SelectionSet is not null) + { + AnalyzeSelectionSet(field.SelectionSet, GetChildContexts(selections), analysis, false); + } + + if (analysis.Step.Requirements.Count > 0) + { + analysis.AddUsages(field, variable); + } + } + else if (selection is InlineFragmentNode fragment) + { + var isTransparent = isRoot && fragment.TypeCondition is null; + + if (isRoot && !isTransparent) + { + analysis.MarkNotUniformlyGated(); + } + + AnalyzeSelectionSet( + fragment.SelectionSet, + NarrowContexts(fragment.TypeCondition, contexts), + analysis, + isTransparent); + } + else if (isRoot) + { + analysis.MarkNotUniformlyGated(); + } + } + } + + private void ResolveRequirementGates(List analyses) + { + _requirementGates.Clear(); + + // No requirement selection is exclusive when the operation has deferred parts. + if (_operation.HasIncrementalParts) + { + return; + } + + var consumers = new Dictionary(); + var selectionsById = new Dictionary(); + + foreach (var analysis in analyses) + { + foreach (var requirement in analysis.Step.Requirements.Values) + { + var selections = ResolveRequirementSelections(requirement); + + if (selections is null) + { + return; + } + + var gate = analysis.GetGate(requirement.Key); + + foreach (var selection in selections) + { + selectionsById[selection.Id] = selection; + consumers[selection.Id] = consumers.TryGetValue(selection.Id, out var existing) + ? (existing.Count + 1, null) + : (1, gate); + } + } + } + + foreach (var (id, consumer) in consumers) + { + if (consumer is { Count: 1, Gate: { } gate } && selectionsById[id].IsInternal) + { + _requirementGates.Add(id, gate); + } + } + } + + private List? ResolveRequirementSelections(OperationRequirement requirement) + { + var contexts = ResolveContexts(requirement.Path); + + if (contexts is null) + { + return null; + } + + var map = ValueSelectionToSelectionSetRewriter.Rewrite([requirement.Map]); + var selections = new List(); + + return CollectRequirementSelections(map, contexts, requirement.InternalAlias, selections) + ? selections + : null; + } + + private bool CollectRequirementSelections( + SelectionSetNode selectionSet, + List contexts, + string? internalAlias, + List selections) + { + if (internalAlias is not null && selectionSet.Selections.Count != 1) + { + return false; + } + + foreach (var selection in selectionSet.Selections) + { + if (selection is FieldNode field) + { + var responseName = internalAlias ?? field.Alias?.Value ?? field.Name.Value; + var children = new List(); + + foreach (var context in contexts) + { + if (context.TryGetSelection(responseName, out var match)) + { + if (!selections.Contains(match)) + { + selections.Add(match); + } + + children.Add(match); + } + } + + if (children.Count == 0) + { + return false; + } + + if (field.SelectionSet is not null + && !CollectRequirementSelections( + field.SelectionSet, + GetChildContexts(children), + internalAlias: null, + selections)) + { + return false; + } + } + else if (selection is InlineFragmentNode fragment + && !CollectRequirementSelections( + fragment.SelectionSet, + NarrowContexts(fragment.TypeCondition, contexts), + internalAlias, + selections)) + { + return false; + } + } + + return true; + } + + private OperationPlanStep Rewrite(StepAnalysis analysis) + { + var step = analysis.Step; + + if (analysis.DataSet is null || analysis.Contexts is null) + { + return step; + } + + var dataSet = RewriteSelectionSet(analysis.DataSet, analysis.Contexts); + var conditions = step.Conditions; + + if (TryCreateNodeCondition(dataSet, out var nodeCondition)) + { + conditions = + [ + .. conditions, + nodeCondition + ]; + Array.Sort(conditions, static (a, b) => string.CompareOrdinal(a.VariableName, b.VariableName)); + } + + if (ReferenceEquals(dataSet, analysis.DataSet) && conditions.Length == step.Conditions.Length) + { + return step; + } + + var selectionSet = ReplaceSelectionSet(step.Definition.SelectionSet, step.Source, 0, dataSet); + + return step with + { + Definition = step.Definition.WithSelectionSet(selectionSet), + Conditions = conditions + }; + } + + private SelectionSetNode RewriteSelectionSet( + SelectionSetNode selectionSet, + List contexts) + { + List? rewritten = null; + + for (var i = 0; i < selectionSet.Selections.Count; i++) + { + var selection = selectionSet.Selections[i]; + var replacement = selection; + + if (selection is FieldNode field) + { + replacement = RewriteField(field, contexts); + } + else if (selection is InlineFragmentNode fragment) + { + var rewrittenSet = RewriteSelectionSet( + fragment.SelectionSet, + NarrowContexts(fragment.TypeCondition, contexts)); + + if (!ReferenceEquals(rewrittenSet, fragment.SelectionSet)) + { + replacement = fragment.WithSelectionSet(rewrittenSet); + } + } + + if (rewritten is null) + { + if (ReferenceEquals(replacement, selection)) + { + continue; + } + + rewritten = [.. selectionSet.Selections.Take(i)]; + } + + rewritten.Add(replacement); + } + + return rewritten is null ? selectionSet : selectionSet.WithSelections(rewritten); + } + + private ISelectionNode RewriteField(FieldNode field, List contexts) + { + var selections = GetSelections(field, contexts); + var rewritten = field; + + if (field.SelectionSet is not null) + { + var childSet = RewriteSelectionSet(field.SelectionSet, GetChildContexts(selections)); + + if (!ReferenceEquals(childSet, field.SelectionSet)) + { + rewritten = field.WithSelectionSet(childSet); + } + } + + var variables = new List(2); + + if (GetVariable(selections) is { } ownVariable) + { + variables.Add(ownVariable); + } + + foreach (var selection in selections) + { + if (_requirementGates.TryGetValue(selection.Id, out var gate) && !variables.Contains(gate)) + { + variables.Add(gate); + } + } + + ISelectionNode result = rewritten; + + foreach (var variable in variables) + { + result = AddSkip(result, variable); + } + + return result; + } + + private bool TryCreateNodeCondition( + SelectionSetNode dataSet, + out ExecutionNodeCondition condition) + { + condition = null!; + + var rootVariables = new List>(); + + if (!TryCollectRootVariables(dataSet, [], rootVariables) || rootVariables.Count == 0) + { + return false; + } + + var common = new HashSet(rootVariables[0]); + + for (var i = 1; i < rootVariables.Count && common.Count > 0; i++) + { + common.IntersectWith(rootVariables[i]); + } + + // A variable shared by all root selections becomes the node condition through the + // common condition extraction. + if (common.Count > 0) + { + return false; + } + + var operands = new List(); + + foreach (var variables in rootVariables) + { + var first = variables.OrderBy(static v => v.Name, StringComparer.Ordinal).First(); + + if (!operands.Contains(first)) + { + operands.Add(first); + } + } + + operands.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name)); + + var key = "node:" + string.Join(",", operands.Select(static o => o.Name)); + + if (!_variablesByKey.TryGetValue(key, out var nodeVariable)) + { + nodeVariable = Register(key, selections: [], [.. operands]); + } + + condition = new ExecutionNodeCondition + { + VariableName = nodeVariable.Name, + PassingValue = false + }; + return true; + } + + private bool TryCollectRootVariables( + SelectionSetNode selectionSet, + List inherited, + List> rootVariables) + { + foreach (var selection in selectionSet.Selections) + { + if (selection is FieldNode field) + { + var variables = GetSkipVariables(field.Directives, inherited); + + if (variables.Count == 0) + { + return false; + } + + rootVariables.Add(variables); + } + else if (selection is InlineFragmentNode { TypeCondition: null } fragment) + { + if (!TryCollectRootVariables( + fragment.SelectionSet, + GetSkipVariables(fragment.Directives, inherited), + rootVariables)) + { + return false; + } + } + else + { + return false; + } + } + + return true; + } + + private List GetSkipVariables( + IReadOnlyList directives, + List inherited) + { + var variables = new List(inherited); + + foreach (var directive in directives) + { + if (directive.Name.Value.Equals(DirectiveNames.Skip.Name, StringComparison.Ordinal) + && directive.Arguments is [{ Value: VariableNode variableNode }] + && _variablesByName.TryGetValue(variableNode.Name.Value, out var variable) + && !variables.Contains(variable)) + { + variables.Add(variable); + } + } + + return variables; + } + + private static ISelectionNode AddSkip(ISelectionNode selection, AuthorizationVariable variable) + { + var directive = new DirectiveNode( + DirectiveNames.Skip.Name, + new ArgumentNode( + DirectiveNames.Skip.Arguments.If, + new VariableNode(null, new NameNode(variable.Name)))); + + // A selection that already carries @skip is wrapped in an inline fragment. + if (selection is FieldNode field && !HasSkip(field.Directives)) + { + return field.WithDirectives([.. field.Directives, directive]); + } + + return new InlineFragmentNode( + null, + null, + [directive], + new SelectionSetNode([selection])); + } + + private static bool HasSkip(IReadOnlyList directives) + { + foreach (var directive in directives) + { + if (directive.Name.Value.Equals(DirectiveNames.Skip.Name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private AuthorizationVariable? GetVariable(List selections) + { + List? protectedSelections = null; + + foreach (var selection in selections) + { + if (selection.HasAuthorization) + { + (protectedSelections ??= []).Add(selection); + } + } + + if (protectedSelections is null) + { + return null; + } + + protectedSelections.Sort(static (a, b) => a.Id.CompareTo(b.Id)); + var key = "selections:" + string.Join(",", protectedSelections.Select(static s => s.Id)); + + return _variablesByKey.TryGetValue(key, out var variable) + ? variable + : Register(key, [.. protectedSelections], []); + } + + private AuthorizationVariable Register( + string key, + ImmutableArray selections, + ImmutableArray operands) + { + var variable = new AuthorizationVariable(_context.NextVariableName(), selections, operands); + _variables.Add(variable); + _variablesByKey.Add(key, variable); + _variablesByName.Add(variable.Name, variable); + return variable; + } + + private List GetSelections(FieldNode field, List contexts) + { + var responseName = field.Alias?.Value ?? field.Name.Value; + var selections = new List(contexts.Count); + + foreach (var context in contexts) + { + if (context.TryGetSelection(responseName, out var selection) && !selections.Contains(selection)) + { + selections.Add(selection); + } + } + + return selections; + } + + private List GetChildContexts(List selections) + { + var childSets = new List(); + + foreach (var selection in selections) + { + AddChildSelectionSets(_operation, selection, childSets); + } + + return childSets; + } + + private List NarrowContexts(NamedTypeNode? typeCondition, List contexts) + { + if (typeCondition is null) + { + return contexts; + } + + var narrowed = new List(contexts.Count); + _schema.Types.TryGetType(typeCondition.Name.Value, allowInaccessibleFields: true, out var type); + + foreach (var context in contexts) + { + if (typeCondition.Name.Value.Equals(context.Type.Name, StringComparison.Ordinal) + || type?.IsAssignableFrom(context.Type) == true) + { + narrowed.Add(context); + } + } + + return narrowed; + } + + private List? ResolveContexts(SelectionPath path) + { + var contexts = new List { _operation.RootSelectionSet }; + + for (var i = 0; i < path.Length; i++) + { + var segment = path[i]; + + if (segment.Kind is SelectionPathSegmentKind.InlineFragment) + { + contexts = NarrowContexts(new NamedTypeNode(segment.Name), contexts); + } + else + { + var selections = new List(contexts.Count); + + foreach (var context in contexts) + { + if (context.TryGetSelection(segment.Name, out var selection) + && !selections.Contains(selection)) + { + selections.Add(selection); + } + } + + contexts = GetChildContexts(selections); + } + + if (contexts.Count == 0) + { + return null; + } + } + + return contexts; + } + + private static bool TryGetSelectionSet( + SelectionSetNode current, + SelectionPath path, + int index, + out SelectionSetNode selectionSet) + { + while (index < path.Length) + { + if (!TryFindChild(current, path[index], out _, out var child)) + { + selectionSet = null!; + return false; + } + + current = child; + index++; + } + + selectionSet = current; + return true; + } + + private static SelectionSetNode ReplaceSelectionSet( + SelectionSetNode current, + SelectionPath path, + int index, + SelectionSetNode replacement) + { + if (index == path.Length) + { + return replacement; + } + + if (!TryFindChild(current, path[index], out var node, out var childSet)) + { + return current; + } + + var rewrittenChild = ReplaceSelectionSet(childSet, path, index + 1, replacement); + + var updatedNode = node is FieldNode field + ? (ISelectionNode)field.WithSelectionSet(rewrittenChild) + : ((InlineFragmentNode)node).WithSelectionSet(rewrittenChild); + + var selections = new List(current.Selections.Count); + + foreach (var selection in current.Selections) + { + selections.Add(ReferenceEquals(selection, node) ? updatedNode : selection); + } + + return current.WithSelections(selections); + } + + private static bool TryFindChild( + SelectionSetNode current, + SelectionPath.Segment segment, + out ISelectionNode node, + out SelectionSetNode selectionSet) + { + foreach (var selection in current.Selections) + { + switch (selection) + { + case InlineFragmentNode fragment + when segment.Kind is SelectionPathSegmentKind.InlineFragment + && fragment.TypeCondition?.Name.Value == segment.Name: + node = fragment; + selectionSet = fragment.SelectionSet; + return true; + + case FieldNode { SelectionSet: { } fieldSet } field + when segment.Kind is SelectionPathSegmentKind.Field + && (field.Alias?.Value == segment.Name || field.Name.Value == segment.Name): + node = field; + selectionSet = fieldSet; + return true; + } + } + + node = null!; + selectionSet = null!; + return false; + } + + private sealed class StepAnalysis( + OperationPlanStep step, + int index, + SelectionSetNode? dataSet, + List? contexts) + { + private Dictionary>? _usages; + private Dictionary? _usageCounts; + private Dictionary? _totalCounts; + private List? _rootVariables; + private bool _isUniformlyGated = true; + + public OperationPlanStep Step { get; } = step; + + public int Index { get; } = index; + + public SelectionSetNode? DataSet { get; } = dataSet; + + public List? Contexts { get; } = contexts; + + public void AddRootVariable(AuthorizationVariable? variable) + { + if (variable is null) + { + _isUniformlyGated = false; + } + else + { + (_rootVariables ??= []).Add(variable); + } + } + + public void MarkNotUniformlyGated() => _isUniformlyGated = false; + + public void AddUsages(FieldNode field, AuthorizationVariable? variable) + { + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var argument in field.Arguments) + { + VariableUsageCounter.Instance.Visit(argument, counts); + } + + foreach (var (name, count) in counts) + { + if (!Step.Requirements.ContainsKey(name)) + { + continue; + } + + _usages ??= new Dictionary>(StringComparer.Ordinal); + _usageCounts ??= new Dictionary(StringComparer.Ordinal); + + if (!_usages.TryGetValue(name, out var variables)) + { + variables = []; + _usages.Add(name, variables); + } + + variables.Add(variable); + _usageCounts[name] = _usageCounts.GetValueOrDefault(name) + count; + } + } + + /// + /// Gets the variable that gates the single use of the requirement variable and every root + /// selection of the step, or null if no such variable exists. + /// + public AuthorizationVariable? GetGate(string requirementKey) + { + if (_usages is null + || _usageCounts is null + || !_usages.TryGetValue(requirementKey, out var variables)) + { + return null; + } + + if (_totalCounts is null) + { + _totalCounts = new Dictionary(StringComparer.Ordinal); + VariableUsageCounter.Instance.Visit(Step.Definition, _totalCounts); + } + + if (_totalCounts.GetValueOrDefault(requirementKey) != _usageCounts[requirementKey]) + { + return null; + } + + var gate = variables[0]; + + foreach (var variable in variables) + { + if (!ReferenceEquals(variable, gate)) + { + return null; + } + } + + if (!_isUniformlyGated || _rootVariables is null) + { + return null; + } + + foreach (var rootVariable in _rootVariables) + { + if (!ReferenceEquals(rootVariable, gate)) + { + return null; + } + } + + return gate; + } + } + + private sealed class VariableUsageCounter : SyntaxWalker> + { + private VariableUsageCounter() + : base(new SyntaxVisitorOptions { VisitArguments = true, VisitDirectives = true }) + { + } + + public static VariableUsageCounter Instance { get; } = new(); + + protected override ISyntaxVisitorAction Enter( + VariableNode node, + Dictionary context) + { + var name = node.Name.Value; + context[name] = context.GetValueOrDefault(name) + 1; + return base.Enter(node, context); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/AuthorizationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/AuthorizationPlanContext.cs new file mode 100644 index 00000000000..41b6c2bc9d0 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/AuthorizationPlanContext.cs @@ -0,0 +1,67 @@ +using System.Collections.Frozen; +using HotChocolate.Fusion.Authorization; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Planning; + +/// +/// The authorization state that is shared by all operations of one plan: the pinned policies +/// and the allocation of the synthetic variable names. +/// +internal sealed class AuthorizationPlanContext +{ + private const string VariableNamePrefix = "__fusion_auth_"; + + private readonly IPolicyResolver _resolver; + private readonly HashSet _reservedNames = new(StringComparer.Ordinal); + private readonly Dictionary<(string DirectiveName, string? PolicyName), IPolicy> _policies = []; + private int _lastVariableId; + + public AuthorizationPlanContext( + IPolicyResolver resolver, + FrozenSet protectedFieldNames, + OperationDefinitionNode operationDefinition) + { + ArgumentNullException.ThrowIfNull(resolver); + ArgumentNullException.ThrowIfNull(protectedFieldNames); + ArgumentNullException.ThrowIfNull(operationDefinition); + + _resolver = resolver; + ProtectedFieldNames = protectedFieldNames; + + foreach (var variableDefinition in operationDefinition.VariableDefinitions) + { + _reservedNames.Add(variableDefinition.Variable.Name.Value); + } + } + + /// + /// Gets the names of all fields of the schema that carry an authorization requirement. + /// + public FrozenSet ProtectedFieldNames { get; } + + public IPolicy GetPolicy(string directiveName, string? policyName) + { + if (!_policies.TryGetValue((directiveName, policyName), out var policy)) + { + policy = _resolver.Resolve(policyName ?? string.Empty, directiveName) + ?? new UnresolvedPolicy(directiveName, policyName); + _policies.Add((directiveName, policyName), policy); + } + + return policy; + } + + public string NextVariableName() + { + string name; + + do + { + name = VariableNamePrefix + ++_lastVariableId; + } + while (_reservedNames.Contains(name)); + + return name; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs index 795581cbcff..c26c91a996e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.BuildExecutionTree.cs @@ -30,6 +30,7 @@ private OperationPlan BuildExecutionPlan( int searchSpace, int expandedNodes, int nextNodeId, + AuthorizationPlanContext? authorization, CancellationToken cancellationToken) { if (operation.IsIntrospectionOnly()) @@ -46,9 +47,17 @@ private OperationPlan BuildExecutionPlan( } var ctx = new ExecutionPlanBuildContext(nextNodeId); - var hasVariables = operationDefinition.VariableDefinitions.Count > 0; + var marker = authorization is null + ? null + : AuthorizationMarker.TryCreate(_schema, authorization, operation); + + planSteps = TransformPlanSteps(planSteps, operationDefinition, marker); + + var hasVariables = operationDefinition.VariableDefinitions.Count > 0 + || marker is { HasVariables: true }; + + operation.SetAuthorization(marker?.CreateAuthorization()); - planSteps = TransformPlanSteps(planSteps, operationDefinition); IndexDependencies(planSteps, ctx); BuildExecutionNodes(planSteps, ctx, _schema, hasVariables, cancellationToken); MergeAndBatchOperations(ctx, _options.EnableRequestGrouping, _options.MergePolicy, _schema); @@ -282,9 +291,9 @@ private static bool ResolvesDeferAnchorField( private static ImmutableList TransformPlanSteps( ImmutableList planSteps, - OperationDefinitionNode originalOperation) + OperationDefinitionNode originalOperation, + AuthorizationMarker? marker) { - var updatedPlanSteps = planSteps; var forwardVariableContext = new ForwardVariableRewriter.Context(); foreach (var variableDef in originalOperation.VariableDefinitions) @@ -292,6 +301,18 @@ private static ImmutableList TransformPlanSteps( forwardVariableContext.Variables[variableDef.Variable.Name.Value] = variableDef; } + if (marker is not null) + { + planSteps = marker.Mark(planSteps); + + foreach (var variableDef in marker.GetVariableDefinitions()) + { + forwardVariableContext.Variables[variableDef.Variable.Name.Value] = variableDef; + } + } + + var updatedPlanSteps = planSteps; + foreach (var step in planSteps) { if (step is not OperationPlanStep operationPlanStep) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs index 19e2cedd455..cac599a84b4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.Defer.cs @@ -122,6 +122,7 @@ private ImmutableArray BuildIncrementalPlans( string shortHash, ImmutableArray routingStates, PlanContextGraph contextGraph, + AuthorizationPlanContext? authorization, CancellationToken cancellationToken) { if (routingStates.IsDefaultOrEmpty) @@ -142,11 +143,6 @@ private ImmutableArray BuildIncrementalPlans( var finalSteps = contextGraph.GetRegisteredSteps(descriptor); var registeredInternalOp = contextGraph.GetRegisteredInternalOperation(descriptor); - var (rootNodes, allNodes) = BuildDeferredExecutionNodes( - registeredInternalOp, - finalSteps, - finalSteps.NextId()); - var compiledOp = AddTypeNameToAbstractSelections( registeredInternalOp, _schema.GetOperationType(registeredInternalOp.Operation)); @@ -156,6 +152,18 @@ private ImmutableArray BuildIncrementalPlans( shortHash, compiledOp); + var marker = authorization is null + ? null + : AuthorizationMarker.TryCreate(_schema, authorization, deferredOperation); + + var (rootNodes, allNodes) = BuildDeferredExecutionNodes( + registeredInternalOp, + finalSteps, + finalSteps.NextId(), + marker); + + deferredOperation.SetAuthorization(marker?.CreateAuthorization()); + var planScopeRequirements = descriptor.Requirements.Count == 0 ? ImmutableArray.Empty : [.. descriptor.Requirements.Values]; @@ -2135,7 +2143,8 @@ private readonly record struct LiftedDeferRequirement( private (ImmutableArray RootNodes, ImmutableArray AllNodes) BuildDeferredExecutionNodes( OperationDefinitionNode deferredOperation, ImmutableList planSteps, - int nextNodeId) + int nextNodeId, + AuthorizationMarker? marker) { if (planSteps.Count == 0) { @@ -2143,9 +2152,12 @@ private readonly record struct LiftedDeferRequirement( } var ctx = new ExecutionPlanBuildContext(nextNodeId); - var hasVariables = deferredOperation.VariableDefinitions.Count > 0; - planSteps = TransformPlanSteps(planSteps, deferredOperation); + planSteps = TransformPlanSteps(planSteps, deferredOperation, marker); + + var hasVariables = deferredOperation.VariableDefinitions.Count > 0 + || marker is { HasVariables: true }; + IndexDependencies(planSteps, ctx); BuildExecutionNodes(planSteps, ctx, _schema, hasVariables, CancellationToken.None); MergeAndBatchOperations(ctx, _options.EnableRequestGrouping, _options.MergePolicy, _schema); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs index 86a19d4bf60..448cd4ac9ea 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Planning/OperationPlanner.cs @@ -1,7 +1,9 @@ +using System.Collections.Frozen; using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Converters; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Language; @@ -28,23 +30,28 @@ public sealed partial class OperationPlanner private readonly NodeFieldSelectionSetPartitioner _nodeFieldSelectionSetPartitioner; private readonly SourceSchemaNodeCandidateResolver _sourceSchemaNodeCandidateResolver; private readonly OperationPlannerOptions _options; + private readonly IPolicyResolver _policyResolver; + private readonly FrozenSet? _protectedFieldNames; private bool? _schemaHasDivergentInterfaceFields; public OperationPlanner( FusionSchemaDefinition schema, - OperationCompiler operationCompiler) - : this(schema, operationCompiler, OperationPlannerOptions.Default) + OperationCompiler operationCompiler, + IPolicyResolver policyResolver) + : this(schema, operationCompiler, OperationPlannerOptions.Default, policyResolver) { } public OperationPlanner( FusionSchemaDefinition schema, OperationCompiler operationCompiler, - OperationPlannerOptions options) + OperationPlannerOptions options, + IPolicyResolver policyResolver) { ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(operationCompiler); ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(policyResolver); _schema = schema; _operationCompiler = operationCompiler; @@ -54,9 +61,38 @@ public OperationPlanner( _nodeFieldSelectionSetPartitioner = new NodeFieldSelectionSetPartitioner(schema); _sourceSchemaNodeCandidateResolver = new SourceSchemaNodeCandidateResolver(schema); _options = options; + _policyResolver = policyResolver; + + if (schema.Features.Get() is { IsUsed: true }) + { + _protectedFieldNames = CollectProtectedFieldNames(schema); + } } - public static Version Version { get; } = new(2, 0, 0); + public static Version Version { get; } = new(2, 1, 0); + + private static FrozenSet CollectProtectedFieldNames(FusionSchemaDefinition schema) + { + var names = new HashSet(StringComparer.Ordinal); + + foreach (var type in schema.Types) + { + if (type is not FusionComplexTypeDefinition complexType) + { + continue; + } + + foreach (var field in complexType.Fields) + { + if (field.HasAuthorization) + { + names.Add(field.Name); + } + } + } + + return names.ToFrozenSet(StringComparer.Ordinal); + } internal OperationPlannerOptions Options => _options; @@ -101,6 +137,10 @@ public OperationPlan CreatePlan( try { + var authorization = _protectedFieldNames is null + ? null + : new AuthorizationPlanContext(_policyResolver, _protectedFieldNames, operationDefinition); + // Interface fields whose ownership diverges across concrete types (for example after an // @override) are expanded into per-concrete inline fragments up front. This runs before // the defer split so deferred selections carry the expansion too, and before selection @@ -296,6 +336,7 @@ node with shortHash, deferRoutingStates, deferContextGraph, + authorization, cancellationToken); } @@ -308,6 +349,7 @@ node with searchSpace, expandedNodes, planSteps.NextId(), + authorization, cancellationToken); if (eventSourceEnabled) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs index 39c9659c2a0..742ecc71f9e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs @@ -302,5 +302,65 @@ internal static string ErrorHelper_OperationKindNotAllowed { return ResourceManager.GetString("ErrorHelper_OperationKindNotAllowed", resourceCulture); } } + + internal static string PolicyEvaluationContext_EntryNotPartOfContext { + get { + return ResourceManager.GetString("PolicyEvaluationContext_EntryNotPartOfContext", resourceCulture); + } + } + + internal static string PolicyResolver_PolicyNotResolved { + get { + return ResourceManager.GetString("PolicyResolver_PolicyNotResolved", resourceCulture); + } + } + + internal static string Operation_AuthorizationAlreadySet { + get { + return ResourceManager.GetString("Operation_AuthorizationAlreadySet", resourceCulture); + } + } + + internal static string FusionAuthorizationOptions_ReadOnly { + get { + return ResourceManager.GetString("FusionAuthorizationOptions_ReadOnly", resourceCulture); + } + } + + internal static string FusionAuthorizationOptions_SchemeChallengeEmpty { + get { + return ResourceManager.GetString("FusionAuthorizationOptions_SchemeChallengeEmpty", resourceCulture); + } + } + + internal static string AuthorizationValidation_SchemeNotRegistered { + get { + return ResourceManager.GetString("AuthorizationValidation_SchemeNotRegistered", resourceCulture); + } + } + + internal static string AuthorizationValidation_NoSchemeRegistered { + get { + return ResourceManager.GetString("AuthorizationValidation_NoSchemeRegistered", resourceCulture); + } + } + + internal static string AuthorizationValidation_NoSchemeLookup { + get { + return ResourceManager.GetString("AuthorizationValidation_NoSchemeLookup", resourceCulture); + } + } + + internal static string AuthorizationValidation_SchemesEmpty { + get { + return ResourceManager.GetString("AuthorizationValidation_SchemesEmpty", resourceCulture); + } + } + + internal static string AuthorizationValidation_SchemesWithoutLookup { + get { + return ResourceManager.GetString("AuthorizationValidation_SchemesWithoutLookup", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx index 8b8d9b931a3..5ceba1f28f3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx @@ -147,4 +147,34 @@ The specified operation kind is not allowed. + + The policy entry is not part of this evaluation context. + + + No policy provider knows the policy '{0}' of the directive '@{1}'. + + + The authorization of the operation was already set and cannot be set again. + + + The authorization options are read-only. + + + The WWW-Authenticate challenge of the authentication scheme '{0}' must not be null, empty or whitespace. + + + The authentication scheme '{0}' of the authorization options is not registered. Register it, remove it from the schemes, or disable the authorization validation. + + + The schema uses authorization directives but no authentication scheme is registered. Register an authentication scheme, or disable the authorization validation. + + + The schema uses authorization directives but the host exposes no authentication schemes. Add the gateway through HotChocolate.Fusion.AspNetCore, or set DisableAuthorizationValidation. + + + The Schemes list is empty; omit it to use every registered scheme or list at least one. + + + The Schemes option lists authentication schemes but the host exposes no authentication schemes. Register the authentication scheme lookup through HotChocolate.Fusion.AspNetCore, or do not set Schemes. + diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AuthorizationHttpTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AuthorizationHttpTests.cs new file mode 100644 index 00000000000..44ce23cebfc --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/AuthorizationHttpTests.cs @@ -0,0 +1,304 @@ +using System.Collections.Immutable; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization; +using HotChocolate.Fusion.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion; + +public class AuthorizationHttpTests : FusionTestBase +{ + private const string SimpleSchema = + """ + type Query { + field: String + } + """; + + private const string ProtectedSchema = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + type Query { + field: String @authenticated + } + """; + + private static readonly ImmutableDictionary s_cookieChallenges = + ImmutableDictionary.Empty.Add("Cookies", "Cookie").Add("Session", "Session"); + + [Fact] + public async Task Request_Should_ChallengeWithMappedChallenges_When_EscalatedAndSchemesAreUnset() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes, + configureGatewayBuilder: b => UseEscalation( + b.ModifyAuthorizationOptions(o => o.SchemeChallenges = s_cookieChallenges), + HttpStatusCode.Unauthorized)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Cookie, Session", GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_ChallengeWithListedSchemesOnly_When_EscalatedAndSchemesAreSet() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes, + configureGatewayBuilder: b => UseEscalation( + b.ModifyAuthorizationOptions( + o => + { + o.Schemes = ImmutableArray.Create("Session"); + o.SchemeChallenges = s_cookieChallenges; + }), + HttpStatusCode.Unauthorized)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Session", GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_ChallengeWithBearer_When_JwtHandlerIsRegisteredUnderCustomName() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: services => services.AddAuthentication().AddJwtBearer("MyJwt", _ => { }), + configureGatewayBuilder: b => UseEscalation(b, HttpStatusCode.Unauthorized)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Bearer", GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_ChallengeWithBearerOnce_When_TwoJwtHandlersAreRegistered() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: services => services + .AddAuthentication() + .AddJwtBearer("JwtA", _ => { }) + .AddJwtBearer("JwtB", _ => { }), + configureGatewayBuilder: b => UseEscalation(b, HttpStatusCode.Unauthorized)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Bearer", GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_OmitChallenge_When_EscalatedAndHandlerHasNoChallengeEntry() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes, + configureGatewayBuilder: b => UseEscalation(b, HttpStatusCode.Unauthorized)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Null(GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_OmitChallenge_When_EscalatedAndNoSchemeIsRegistered() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => UseEscalation(b, HttpStatusCode.Unauthorized)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Null(GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_ReturnForbiddenWithoutChallenge_When_EscalatedAsUnauthorized() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes, + configureGatewayBuilder: b => UseEscalation(b, HttpStatusCode.Forbidden)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(GetChallenge(response)); + } + + [Fact] + public async Task Request_Should_ReturnOk_When_ResultIsNotEscalated() + { + // arrange + using var server = CreateSourceSchema("A", SimpleSchema); + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(GetChallenge(response)); + } + + [Fact] + public async Task Startup_Should_Fail_When_SchemaUsesAuthorizationAndNoAuthenticationIsRegistered() + { + // arrange + using var server = CreateSourceSchema("A", ProtectedSchema); + + // act + var act = async () => await CreateCompositeSchemaAsync([("A", server)]); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The schema uses authorization directives but no authentication scheme is registered. " + + "Register an authentication scheme, or disable the authorization validation.", + exception.Message); + } + + [Fact] + public async Task Gateway_Should_ReturnOk_When_SchemaUsesAuthorizationAndSchemeIsRegistered() + { + // arrange + using var server = CreateSourceSchema("A", ProtectedSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Startup_Should_Fail_When_ListedSchemeIsNotRegistered() + { + // arrange + using var server = CreateSourceSchema("A", ProtectedSchema); + + // act + var act = async () => await CreateCompositeSchemaAsync( + [("A", server)], + configureServices: AddCookieSchemes, + configureGatewayBuilder: b => b.ModifyAuthorizationOptions( + o => o.Schemes = ImmutableArray.Create("Session", "Bearer"))); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The authentication scheme 'Bearer' of the authorization options is not registered. " + + "Register it, remove it from the schemes, or disable the authorization validation.", + exception.Message); + } + + [Fact] + public async Task Gateway_Should_ReturnOk_When_ValidationIsDisabledAndNoAuthenticationIsRegistered() + { + // arrange + using var server = CreateSourceSchema("A", ProtectedSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => b.ModifyAuthorizationOptions( + o => o.DisableAuthorizationValidation = true)); + + // act + using var response = await PostAsync(gateway); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static IFusionGatewayBuilder UseEscalation( + IFusionGatewayBuilder builder, + HttpStatusCode statusCode) + => builder.UseRequest( + (_, _) => async context => + { + var schemeResolver = context.Schema.Services.GetRequiredService(); + var result = OperationResult.FromError(ErrorBuilder.New().SetMessage("Escalated.").Build()); + result.ContextData = result.ContextData.Add(ExecutionContextData.HttpStatusCode, statusCode); + + if (await schemeResolver.GetChallengeAsync(context.RequestAborted) is { } challenge) + { + result.ContextData = result.ContextData.Add( + ExecutionContextData.WwwAuthenticateHeaderValue, + challenge); + } + + context.Result = result; + }, + before: WellKnownRequestMiddleware.OperationExecutionMiddleware, + allowMultiple: true); + + private static void AddCookieSchemes(IServiceCollection services) + => services + .AddAuthentication() + .AddCookie("Cookies") + .AddCookie("Session"); + + private static async Task PostAsync(Gateway gateway) + { + using var client = gateway.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/graphql") + { + Content = new StringContent("""{"query":"{ field }"}""", Encoding.UTF8, "application/json") + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/graphql-response+json")); + + return await client.SendAsync(request, TestContext.Current.CancellationToken); + } + + private static string? GetChallenge(HttpResponseMessage response) + => response.Headers.TryGetValues("WWW-Authenticate", out var values) + ? string.Join(", ", values) + : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj index 8b6c52280e7..b0d3ea49340 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/HotChocolate.Fusion.AspNetCore.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/AuthDirectiveIntakeTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/AuthDirectiveIntakeTests.cs new file mode 100644 index 00000000000..d3cedb74a3f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/AuthDirectiveIntakeTests.cs @@ -0,0 +1,392 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class AuthDirectiveIntakeTests +{ + [Fact] + public void Transform_Should_RewriteToFusionDirectives_When_DirectivesAreImportedByName() + { + // arrange + const string federationSdl = + """ + schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: ["@key", "@authenticated", "@requiresScopes", "@policy"] + ) { + query: Query + } + + type Query { + product(id: ID!): Product @authenticated + secret: String @requiresScopes(scopes: [["read", "write"], ["admin"]]) + audited: String @policy(policies: [["auditor"]]) + single: String @requiresScopes(scopes: "read") + flat: String @policy(policies: ["a", "b"]) + } + + type Product @key(fields: "id") @authenticated { + id: ID! + name: String @requiresScopes(scopes: [["product:read"]]) + } + + interface Node @policy(policies: [["admin"]]) { + id: ID! @authenticated + } + + enum Role @authenticated { + ADMIN + } + + scalar Secret @requiresScopes(scopes: [["secret"]]) + + scalar federation__Scope + scalar federation__Policy + scalar FieldSet + + directive @key(fields: FieldSet!) repeatable on OBJECT | INTERFACE + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @requiresScopes(scopes: [[federation__Scope!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @policy(policies: [[federation__Policy!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); + } + + [Fact] + public void Transform_Should_RewriteToFusionDirectives_When_ImportsAreRenamed() + { + // arrange + const string federationSdl = + """ + schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: [ + "@key" + { name: "@authenticated", as: "@auth" } + { name: "@requiresScopes", as: "@scopes" } + { name: "@policy", as: "@apolloPolicy" } + { name: "Scope", as: "AuthScope" } + { name: "Policy", as: "AuthPolicy" } + ] + ) { + query: Query + } + + type Query { + a: String @auth + b: String @scopes(scopes: [["read"]]) + c: String @apolloPolicy(policies: [["admin"]]) + } + + interface Node @apolloPolicy(policies: [["admin"]]) { + id: ID! @auth + } + + scalar AuthScope + scalar AuthPolicy + scalar FieldSet + + directive @key(fields: FieldSet!) repeatable on OBJECT | INTERFACE + directive @auth on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @scopes(scopes: [[AuthScope!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @apolloPolicy(policies: [[AuthPolicy!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); + } + + [Fact] + public void Transform_Should_RewriteToFusionDirectives_When_NamesAreNamespaced() + { + // arrange + const string federationSdl = + """ + schema + @link(url: "https://specs.apollo.dev/federation/v2.6", as: "fed") { + query: Query + } + + type Query { + a: String @fed__authenticated + b: String @fed__requiresScopes(scopes: [["read"]]) + c: String @fed__policy(policies: [["admin"]]) + } + + scalar fed__Scope + scalar fed__Policy + + directive @fed__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @fed__requiresScopes(scopes: [[fed__Scope!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @fed__policy(policies: [[fed__Policy!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM + directive @link(url: String! import: [String!] as: String) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); + } + + [Fact] + public void Transform_Should_ResolveByImportedName_When_RenamesSwapTheDirectives() + { + // arrange + const string federationSdl = + """ + schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: [ + { name: "@requiresScopes", as: "@policy" } + { name: "@policy", as: "@requiresScopes" } + ] + ) { + query: Query + } + + type Query { + a: String @policy(scopes: [["read"]]) + b: String @requiresScopes(policies: [["admin"]]) + } + + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); + } + + [Fact] + public void Transform_Should_KeepUserType_When_ItIsNamedLikeTheApolloScalar() + { + // arrange + const string federationSdl = + """ + schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@requiresScopes"]) { + query: Query + } + + type Query { + policy: Policy @requiresScopes(scopes: [["read"]]) + } + + type Policy { + id: ID! + } + + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsSuccess); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value, "Transformed SDL", "graphql") + .MatchMarkdownSnapshot(); + } + + [Fact] + public void Transform_Should_Fail_When_ComposeDirectiveIsDeclared() + { + // arrange + const string federationSdl = + """ + schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@composeDirective"]) { + query: Query + } + + type Query { + a: String + } + + directive @composeDirective(name: String!) repeatable on SCHEMA + directive @link(url: String! import: [String!]) repeatable on SCHEMA + """; + + // act + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True(result.IsFailure); + Assert.Single(result.Errors); + } + + [Fact] + public void Compose_Should_MergeToFusionAuthorization_When_SubgraphUsesApolloDirectives() + { + // arrange + const string a = + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: [ + "@key" + "@shareable" + "@authenticated" + { name: "@requiresScopes", as: "@scopes" } + "@policy" + ] + ) + + type Query { + product(id: ID!): Product + me: String @authenticated + } + + type Product @key(fields: "id") @authenticated { + id: ID! + price: Float @shareable @scopes(scopes: [["read"], ["admin"]]) + cost: Float @policy(policies: [["finance"]]) + } + + interface Node @policy(policies: [["admin"]]) { + id: ID! + } + """; + + const string b = + """ + type Query { + productById(id: ID!): Product @lookup + } + + type Product @key(fields: "id") { + id: ID! + price: Float @shareable @requiresScopes(scopes: [["pricing"]]) + } + + interface Node @policy(policies: [["admin"]]) { + id: ID! + } + """; + + var log = new CompositionLog(); + var composer = new SchemaComposer( + [new SourceSchemaText("A", a), new SourceSchemaText("B", b)], + new SchemaComposerOptions(), + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True( + result.IsSuccess, + result.IsSuccess ? null : string.Join("\n", log.Select(e => e.Message))); + result.Value.MatchSnapshot(extension: ".graphql"); + } + + [Fact] + public void Compose_Should_Fail_When_ScopeLiteralIsNotAString() + { + // arrange + const string a = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@requiresScopes"]) + + type Query { + me: String @requiresScopes(scopes: [[1]]) + } + """; + + var log = new CompositionLog(); + var composer = new SchemaComposer( + [new SourceSchemaText("A", a)], + new SchemaComposerOptions(), + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsFailure); + Assert.Contains(log, e => e.Code == LogEntryCodes.AuthorizationDirectiveArgumentInvalid); + } + + [Fact] + public void Compose_Should_Fail_When_ScopeGroupIsEmpty() + { + // arrange + const string a = + """ + extend schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@requiresScopes"]) + + type Query { + me: String @requiresScopes(scopes: [[]]) + } + """; + + var log = new CompositionLog(); + var composer = new SchemaComposer( + [new SourceSchemaText("A", a)], + new SchemaComposerOptions(), + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsFailure); + log.Select(e => e.ToString()).MatchInlineSnapshots( + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query.me' in schema 'A' 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 [[]].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query.me", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Compose_Should_MergeToFusionAuthorization_When_SubgraphUsesApolloDirectives.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Compose_Should_MergeToFusionAuthorization_When_SubgraphUsesApolloDirectives.graphql new file mode 100644 index 00000000000..a88918d73f9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Compose_Should_MergeToFusionAuthorization_When_SubgraphUsesApolloDirectives.graphql @@ -0,0 +1,247 @@ +schema + @fusion__execution( + nodeResolution: GATEWAY + shareableFieldRuntimeTypeRouting: SOURCE_LOCAL + ) { + query: Query +} + +type Query @fusion__type(schema: A) @fusion__type(schema: B) { + me: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + product(id: ID! @fusion__inputField(schema: A)): Product + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + productById(id: ID! @fusion__inputField(schema: B)): Product + @fusion__authorization(authenticated: true) + @fusion__field(schema: B) +} + +type Product + @fusion__authorization(authenticated: true) + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__lookup( + schema: A + key: "id" + field: "fusion__lookup_productById(id: ID!): Product" + map: ["id"] + path: null + internal: true + ) + @fusion__lookup( + schema: B + key: "id" + field: "productById(id: ID!): Product" + map: ["id"] + path: null + internal: false + ) { + cost: Float + @fusion__authorization(authenticated: true, policies: [["finance"]]) + @fusion__field(schema: A) + id: ID! + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + @fusion__field(schema: B) + price: Float + @fusion__authorization( + authenticated: true + scopes: [["admin", "pricing"], ["pricing", "read"]] + ) + @fusion__field(schema: A) + @fusion__field(schema: B) +} + +enum fusion__NodeResolution { + GATEWAY + SOURCE_SCHEMA +} + +"The fusion__Schema enum is a generated type used within an execution schema document to refer to a source schema in a type-safe manner." +enum fusion__Schema { + A @fusion__schema_metadata(name: "A", kind: "ApolloFederation") + B @fusion__schema_metadata(name: "B") +} + +enum fusion__ShareableFieldRuntimeTypeRouting { + SOURCE_LOCAL + COMMON_RUNTIME_TYPES +} + +"The fusion__FieldDefinition scalar is used to represent a GraphQL field definition specified in the GraphQL spec." +scalar fusion__FieldDefinition + +"The fusion__FieldSelectionMap scalar is used to represent the FieldSelectionMap type specified in the GraphQL Composite Schemas Spec." +scalar fusion__FieldSelectionMap + +"The fusion__FieldSelectionPath scalar is used to represent a path of field names relative to the Query type." +scalar fusion__FieldSelectionPath + +"The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." +scalar fusion__FieldSelectionSet + +"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." +directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] +) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + +"The @fusion__cost directive specifies cost metadata for each source schema." +directive @fusion__cost( + "The name of the source schema that defined the cost metadata." + schema: fusion__Schema! + "The weight defined in the source schema." + weight: String! +) repeatable on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | INPUT_FIELD_DEFINITION + +directive @fusion__cost_options(defaultListSize: Int) on SCHEMA + +"The @fusion__enumValue directive specifies which source schema provides an enum value." +directive @fusion__enumValue( + "The name of the source schema that provides the specified enum value." + schema: fusion__Schema! +) repeatable on ENUM_VALUE + +directive @fusion__eventStream( + broker: String + cursorArgument: String + cursorField: String + message: fusion__FieldSelectionSet! + schema: fusion__Schema! + topics: [String!] +) on FIELD_DEFINITION + +directive @fusion__execution( + nodeResolution: fusion__NodeResolution! = GATEWAY + shareableFieldRuntimeTypeRouting: fusion__ShareableFieldRuntimeTypeRouting! = SOURCE_LOCAL +) on SCHEMA + +"The @fusion__field directive specifies which source schema provides a field in a composite type and what execution behavior it has." +directive @fusion__field( + "Indicates that this field is only partially provided and must be combined with `provides`." + partial: Boolean! = false + "A selection set of fields this field provides in the composite schema." + provides: fusion__FieldSelectionSet + "The name of the source schema that originally provided this field." + schema: fusion__Schema! + "Indicates that the source field was declared as external before connector preprocessing." + sourceExternal: Boolean! = false + "The field type in the source schema if it differs in nullability or structure." + sourceType: String +) repeatable on FIELD_DEFINITION + +"The @fusion__gateway_field directive marks a field that is implemented by the gateway itself rather than resolved from an underlying source schema, such as the global object identification node field." +directive @fusion__gateway_field on FIELD_DEFINITION + +"The @fusion__implements directive specifies on which source schema an interface is implemented by an object or interface type." +directive @fusion__implements( + "The name of the interface type." + interface: String! + "The name of the source schema on which the annotated type implements the specified interface." + schema: fusion__Schema! +) repeatable on OBJECT | INTERFACE + +"The @fusion__inaccessible directive is used to prevent specific type system members from being accessible through the client-facing composite schema, even if they are accessible in the underlying source schemas." +directive @fusion__inaccessible on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +"The @fusion__inputField directive specifies which source schema provides an input field in a composite input type." +directive @fusion__inputField( + "The name of the source schema that originally provided this input field." + schema: fusion__Schema! + "The field type in the source schema if it differs in nullability or structure." + sourceType: String +) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION + +"The @fusion__interfaceObject directive specifies the source schemas that expose an interface as an @interfaceObject stand-in, so values of the interface produced by those schemas are opaque." +directive @fusion__interfaceObject( + "The name of the source schema that exposes this interface as an @interfaceObject stand-in." + schema: fusion__Schema! +) repeatable on INTERFACE + +"The @fusion__listSize directive specifies list size metadata for each source schema." +directive @fusion__listSize( + "The assumed size of the list as defined in the source schema." + assumedSize: Int + "The single slicing argument requirement of the list as defined in the source schema." + requireOneSlicingArgument: Boolean + "The name of the source schema that defined the list size metadata." + schema: fusion__Schema! + "The sized fields of the list as defined in the source schema." + sizedFields: [String!] + "The slicing argument default value of the list as defined in the source schema." + slicingArgumentDefaultValue: Int + "The slicing arguments of the list as defined in the source schema." + slicingArguments: [String!] +) repeatable on FIELD_DEFINITION + +"The @fusion__lookup directive specifies how the distributed executor can resolve data for an entity type from a source schema by a stable key." +directive @fusion__lookup( + "The GraphQL field definition in the source schema that can be used to look up the entity." + field: fusion__FieldDefinition! + "Is the lookup meant as an entry point or just to provide more data." + internal: Boolean! = false + "A selection set on the annotated entity type that describes the stable key for the lookup." + key: fusion__FieldSelectionSet! + "The map describes how the key values are resolved from the annotated entity type." + map: [fusion__FieldSelectionMap!]! + "The path to the lookup field relative to the Query type." + path: fusion__FieldSelectionPath + "The name of the source schema where the annotated entity type can be looked up from." + schema: fusion__Schema! +) repeatable on OBJECT | INTERFACE | UNION + +"The @fusion__requires directive specifies if a field has requirements on a source schema." +directive @fusion__requires( + "The GraphQL field definition in the source schema that this field depends on." + field: fusion__FieldDefinition! + "The map describes how the argument values for the source schema are resolved from the arguments of the field exposed in the client-facing composite schema and from required data relative to the current type." + map: [fusion__FieldSelectionMap]! + "A selection set on the annotated field that describes its requirements." + requirements: fusion__FieldSelectionSet! + "The name of the source schema where this field has requirements to data on other source schemas." + schema: fusion__Schema! +) repeatable on FIELD_DEFINITION + +"The @fusion__schema_metadata directive is used to provide additional metadata for a source schema." +directive @fusion__schema_metadata( + allowNonResolvableInterfaceObjects: Boolean + kind: String + "The name of the source schema." + name: String! +) on ENUM_VALUE + +"The @fusion__type directive specifies which source schemas provide parts of a composite type." +directive @fusion__type( + "The name of the source schema that originally provided part of the annotated type." + schema: fusion__Schema! +) repeatable on SCALAR | OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT + +"The @fusion__unionMember directive specifies which source schema provides a member type of a union." +directive @fusion__unionMember( + "The name of the member type." + member: String! + "The name of the source schema that provides the specified member type." + schema: fusion__Schema! +) repeatable on UNION diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_KeepUserType_When_ItIsNamedLikeTheApolloScalar.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_KeepUserType_When_ItIsNamedLikeTheApolloScalar.md new file mode 100644 index 00000000000..2b971c859b9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_KeepUserType_When_ItIsNamedLikeTheApolloScalar.md @@ -0,0 +1,36 @@ +# Transform_Should_KeepUserType_When_ItIsNamedLikeTheApolloScalar + +## Apollo Federation SDL + +```graphql +schema + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@requiresScopes"]) { + query: Query +} + +type Query { + policy: Policy @requiresScopes(scopes: [["read"]]) +} + +type Policy { + id: ID! +} + +directive @link(url: String! import: [String!]) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + policy: Policy @requiresScopes(scopes: [["read"]]) +} + +type Policy { + id: ID! +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_ResolveByImportedName_When_RenamesSwapTheDirectives.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_ResolveByImportedName_When_RenamesSwapTheDirectives.md new file mode 100644 index 00000000000..0b009bc12d8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_ResolveByImportedName_When_RenamesSwapTheDirectives.md @@ -0,0 +1,36 @@ +# Transform_Should_ResolveByImportedName_When_RenamesSwapTheDirectives + +## Apollo Federation SDL + +```graphql +schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: [ + { name: "@requiresScopes", as: "@policy" } + { name: "@policy", as: "@requiresScopes" } + ] + ) { + query: Query +} + +type Query { + a: String @policy(scopes: [["read"]]) + b: String @requiresScopes(policies: [["admin"]]) +} + +directive @link(url: String! import: [String!]) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + a: String @requiresScopes(scopes: [["read"]]) + b: String @policy(policies: [["admin"]]) +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_DirectivesAreImportedByName.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_DirectivesAreImportedByName.md new file mode 100644 index 00000000000..f3223947398 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_DirectivesAreImportedByName.md @@ -0,0 +1,80 @@ +# Transform_Should_RewriteToFusionDirectives_When_DirectivesAreImportedByName + +## Apollo Federation SDL + +```graphql +schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: ["@key", "@authenticated", "@requiresScopes", "@policy"] + ) { + query: Query +} + +type Query { + product(id: ID!): Product @authenticated + secret: String @requiresScopes(scopes: [["read", "write"], ["admin"]]) + audited: String @policy(policies: [["auditor"]]) + single: String @requiresScopes(scopes: "read") + flat: String @policy(policies: ["a", "b"]) +} + +type Product @key(fields: "id") @authenticated { + id: ID! + name: String @requiresScopes(scopes: [["product:read"]]) +} + +interface Node @policy(policies: [["admin"]]) { + id: ID! @authenticated +} + +enum Role @authenticated { + ADMIN +} + +scalar Secret @requiresScopes(scopes: [["secret"]]) + +scalar federation__Scope +scalar federation__Policy +scalar FieldSet + +directive @key(fields: FieldSet!) repeatable on OBJECT | INTERFACE +directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @requiresScopes(scopes: [[federation__Scope!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @policy(policies: [[federation__Policy!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @link(url: String! import: [String!]) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + audited: String @policy(policies: [["auditor"]]) + flat: String @policy(policies: [["a"], ["b"]]) + fusion__lookup_productById(id: ID!): Product @internal @lookup + product(id: ID!): Product @authenticated + secret: String @requiresScopes(scopes: [["read", "write"], ["admin"]]) + single: String @requiresScopes(scopes: [["read"]]) +} + +type Product @key(fields: "id") @authenticated { + id: ID! + name: String @requiresScopes(scopes: [["product:read"]]) +} + +interface Node @policy(policies: [["admin"]]) { + id: ID! @authenticated +} + +enum Role @authenticated { + ADMIN +} + +scalar Secret @requiresScopes(scopes: [["secret"]]) +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_ImportsAreRenamed.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_ImportsAreRenamed.md new file mode 100644 index 00000000000..a456341c593 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_ImportsAreRenamed.md @@ -0,0 +1,60 @@ +# Transform_Should_RewriteToFusionDirectives_When_ImportsAreRenamed + +## Apollo Federation SDL + +```graphql +schema + @link( + url: "https://specs.apollo.dev/federation/v2.6" + import: [ + "@key" + { name: "@authenticated", as: "@auth" } + { name: "@requiresScopes", as: "@scopes" } + { name: "@policy", as: "@apolloPolicy" } + { name: "Scope", as: "AuthScope" } + { name: "Policy", as: "AuthPolicy" } + ] + ) { + query: Query +} + +type Query { + a: String @auth + b: String @scopes(scopes: [["read"]]) + c: String @apolloPolicy(policies: [["admin"]]) +} + +interface Node @apolloPolicy(policies: [["admin"]]) { + id: ID! @auth +} + +scalar AuthScope +scalar AuthPolicy +scalar FieldSet + +directive @key(fields: FieldSet!) repeatable on OBJECT | INTERFACE +directive @auth on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @scopes(scopes: [[AuthScope!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @apolloPolicy(policies: [[AuthPolicy!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @link(url: String! import: [String!]) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + a: String @authenticated + b: String @requiresScopes(scopes: [["read"]]) + c: String @policy(policies: [["admin"]]) +} + +interface Node @policy(policies: [["admin"]]) { + id: ID! @authenticated +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_NamesAreNamespaced.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_NamesAreNamespaced.md new file mode 100644 index 00000000000..498e2ca0193 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/AuthDirectiveIntakeTests.Transform_Should_RewriteToFusionDirectives_When_NamesAreNamespaced.md @@ -0,0 +1,40 @@ +# Transform_Should_RewriteToFusionDirectives_When_NamesAreNamespaced + +## Apollo Federation SDL + +```graphql +schema + @link(url: "https://specs.apollo.dev/federation/v2.6", as: "fed") { + query: Query +} + +type Query { + a: String @fed__authenticated + b: String @fed__requiresScopes(scopes: [["read"]]) + c: String @fed__policy(policies: [["admin"]]) +} + +scalar fed__Scope +scalar fed__Policy + +directive @fed__authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @fed__requiresScopes(scopes: [[fed__Scope!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @fed__policy(policies: [[fed__Policy!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | SCALAR | ENUM +directive @link(url: String! import: [String!] as: String) repeatable on SCHEMA +``` + +## Transformed SDL + +```graphql +schema { + query: Query +} + +type Query { + a: String @authenticated + b: String @requiresScopes(scopes: [["read"]]) + c: String @policy(policies: [["admin"]]) +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/RepeatedKeyLookupTests.Compose_Should_Succeed_When_SameKeyIsRepeatedOnTypeAndExtension.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/RepeatedKeyLookupTests.Compose_Should_Succeed_When_SameKeyIsRepeatedOnTypeAndExtension.graphql index 7efade3e1f7..56a4ec45fa8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/RepeatedKeyLookupTests.Compose_Should_Succeed_When_SameKeyIsRepeatedOnTypeAndExtension.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/RepeatedKeyLookupTests.Compose_Should_Succeed_When_SameKeyIsRepeatedOnTypeAndExtension.graphql @@ -52,6 +52,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet +"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." +directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] +) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/AuthorizationInheritedRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/AuthorizationInheritedRuleTests.cs new file mode 100644 index 00000000000..79531cc61a8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/AuthorizationInheritedRuleTests.cs @@ -0,0 +1,157 @@ +namespace HotChocolate.Fusion.PostMergeValidationRules; + +public sealed class AuthorizationInheritedRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new AuthorizationInheritedRule(); + + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Validate_Should_Succeed_When_NoMemberIsProtectedByInheritance() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { search: SearchResult } + + interface SearchResult { title: String @authenticated } + + type Article implements SearchResult { title: String @authenticated } + + type Video implements SearchResult { title: String @authenticated } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_WarnForUnannotatedFields_When_ImplementationFieldIsProtected() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { search: SearchResult } + + interface SearchResult { title: String } + + type Article implements SearchResult { title: String @authenticated } + + {{Directives}} + """, + $$""" + # Schema B + type Query { video: Video } + + type Video implements SearchResult { title: String } + + interface SearchResult { title: String } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The member 'SearchResult.title' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from Article.title -> SearchResult.title in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "SearchResult.title", + "extensions": {} + } + """, + """ + { + "message": "The member 'Video.title' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from Article.title -> SearchResult.title -> Video.title in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "Video.title", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_WarnForFieldsOnly_When_ImplementationTypeIsProtected() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { search: SearchResult } + + interface SearchResult { title: String } + + type Article implements SearchResult @requiresScopes(scopes: [["read"]]) { + title: String + } + + type Video implements SearchResult { title: String } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The member 'SearchResult.title' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from Article.title -> SearchResult.title in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "SearchResult.title", + "extensions": {} + } + """, + """ + { + "message": "The member 'Video.title' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from Article.title -> SearchResult.title -> Video.title in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "Video.title", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_WarnForUnannotatedType_When_InterfaceTypeIsProtected() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { video: Video } + + interface SearchResult @authenticated { title: String } + + type Video implements SearchResult { title: String } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The member 'Video' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from SearchResult -> Video in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "Video", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/AuthorizationTransitiveRequirementsRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/AuthorizationTransitiveRequirementsRuleTests.cs new file mode 100644 index 00000000000..09aeb253cd5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PostMergeValidationRules/AuthorizationTransitiveRequirementsRuleTests.cs @@ -0,0 +1,1169 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion.PostMergeValidationRules; + +public sealed class AuthorizationTransitiveRequirementsRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new AuthorizationTransitiveRequirementsRule(); + + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Validate_Should_Succeed_When_NoMemberIsProtected() + { + // arrange & act & assert + AssertValid(RequireSchemas(requiring: "", dependency: "")); + } + + [Fact] + public void Validate_Should_Succeed_When_DependencyIsNotProtected() + { + // arrange & act & assert + AssertValid(RequireSchemas(requiring: "@authenticated", dependency: "")); + } + + [Fact] + public void Validate_Should_Succeed_When_RequiringFieldHasTheSameScopes() + { + // arrange & act & assert + AssertValid( + RequireSchemas( + requiring: """@requiresScopes(scopes: [["read"]])""", + dependency: """@requiresScopes(scopes: [["read"]])""")); + } + + [Fact] + public void Validate_Should_Succeed_When_RequiringFieldStrengthensTheScopes() + { + // arrange & act & assert + AssertValid( + RequireSchemas( + requiring: """@requiresScopes(scopes: [["read", "pii"]])""", + dependency: """@requiresScopes(scopes: [["read"]])""")); + } + + [Fact] + public void Validate_Should_Succeed_When_EveryAlternativeCoversSomeDependencyAlternative() + { + // arrange & act & assert + AssertValid( + RequireSchemas( + requiring: """@requiresScopes(scopes: [["a", "x"], ["b", "y"]])""", + dependency: """@requiresScopes(scopes: [["a"], ["b"]])""")); + } + + [Fact] + public void Validate_Should_Succeed_When_RequiringFieldHasTheSamePolicyAndAuthentication() + { + // arrange & act & assert + AssertValid( + RequireSchemas( + requiring: """@authenticated @policy(policies: [["tenant"]])""", + dependency: """@authenticated @policy(policies: [["tenant"]])""")); + } + + [Fact] + public void Validate_Should_Succeed_When_RequiringTypeCarriesTheDependencyRequirement() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Product @key(fields: "id") @requiresScopes(scopes: [["read"]]) { + id: ID! + delivery(weight: Int! @require(field: "weight")): Int + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + weight: Int @requiresScopes(scopes: [["read"]]) + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_NestedSelectionsAreCovered() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + delivery(weight: Int! @require(field: "dimension.weight")): Int + @requiresScopes(scopes: [["read", "pii"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + dimension: Dimension @requiresScopes(scopes: [["read"]]) + } + + type Dimension { + weight: Int @requiresScopes(scopes: [["pii"]]) + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_EachFieldOfAChainCoversItsDependency() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + label(price: Int! @require(field: "price")): String + @requiresScopes(scopes: [["finance", "pii"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + price(cost: Int! @require(field: "cost")): Int + @requiresScopes(scopes: [["finance"]]) + } + + {{Directives}} + """, + $$""" + # Schema C + type Product @key(fields: "id") { + id: ID! + cost: Int @requiresScopes(scopes: [["finance"]]) + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_LookupKeyIsCoveredByTheServedFields() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { products: [Product] } + + type Product @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { productById(id: ID!): Product @lookup } + + type Product @key(fields: "id") { + id: ID! + name: String @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_LookupKeyIsServedByTheLookupSchemaOnly() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { productById(id: ID!): Product @lookup } + + type Product @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + name: String + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_IsDirectiveSelectsAProtectedKeyOnTheLookupField() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { products: [Product] } + + type Product @key(fields: "id") { + id: ID! @authenticated + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { productById(key: ID! @is(field: "id")): Product @lookup } + + type Product @key(fields: "id") { + id: ID! + name: String @authenticated + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_RequiringFieldDeclaresNoRequirement() + { + // arrange & act & assert + AssertInvalid( + RequireSchemas( + requiring: "", + dependency: """@requiresScopes(scopes: [["read"]])"""), + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Product.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.weight'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_RequiringFieldDeclaresDifferentScopes() + { + // arrange & act & assert + AssertInvalid( + RequireSchemas( + requiring: """@requiresScopes(scopes: [["other"]])""", + dependency: """@requiresScopes(scopes: [["read"]])"""), + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Product.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.weight'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_OneAlternativeDoesNotCoverTheDependency() + { + // arrange & act & assert + AssertInvalid( + RequireSchemas( + requiring: """@requiresScopes(scopes: [["a"], ["b"]])""", + dependency: """@requiresScopes(scopes: [["a"]])"""), + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Product.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.weight'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_DependencyIsAuthenticatedAndRequiringFieldIsNot() + { + // arrange & act & assert + AssertInvalid( + RequireSchemas( + requiring: """@requiresScopes(scopes: [["read"]])""", + dependency: """@authenticated @requiresScopes(scopes: [["read"]])"""), + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Product.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.weight'. Not covered: @authenticated.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_PolicyIsNotCovered() + { + // arrange & act & assert + AssertInvalid( + RequireSchemas( + requiring: """@authenticated @policy(policies: [["tenant"]])""", + dependency: """@authenticated @policy(policies: [["tenant", "audit"]])"""), + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Product.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.weight'. Not covered: @policy.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_ListEveryUncoveredDirective_When_NothingIsCovered() + { + // arrange & act & assert + AssertInvalid( + RequireSchemas( + requiring: "", + dependency: + """ + @authenticated + @requiresScopes(scopes: [["read"]]) + @policy(policies: [["tenant"]]) + """), + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Product.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.weight'. Not covered: @authenticated, @requiresScopes, @policy.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_DependencyReturnTypeRequirementIsFlattenedOntoTheField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + delivery(weight: Int! @require(field: "dimension.weight")): Int + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + dimension: Dimension + } + + type Dimension @authenticated { + weight: Int + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Dimension.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Dimension.weight'. Not covered: @authenticated.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The field 'Product.delivery' depends on 'Product.dimension' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Product.dimension'. Not covered: @authenticated.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_NestedSelectionReachesAProtectedField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + delivery(weight: Int! @require(field: "dimension.weight")): Int + @requiresScopes(scopes: [["read"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + dimension: Dimension @requiresScopes(scopes: [["read"]]) + } + + type Dimension { + weight: Int @requiresScopes(scopes: [["pii"]]) + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Dimension.weight' through 'Product.delivery(weight:)' in schema 'A', but does not declare all authorization requirements of 'Dimension.weight'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_ObjectConstructionReachesAProtectedField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + delivery( + dimension: DimensionInput! + @require(field: "{ size: dimension.size, weight: dimension.weight }") + ): Int + } + + input DimensionInput { + size: Int! + weight: Int! + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + dimension: Dimension! + } + + type Dimension { + size: Int! + weight: Int! @authenticated + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Product.delivery' depends on 'Dimension.weight' through 'Product.delivery(dimension:)' in schema 'A', but does not declare all authorization requirements of 'Dimension.weight'. Not covered: @authenticated.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.delivery", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_ChoiceBranchesWithTypeConditionsReachAProtectedField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + headline( + title: String! + @require(field: "media.isbn | media.title") + ): String + } + + interface Media { + title: String! + } + + type Book implements Media { + title: String! + isbn: String! + } + + type Movie implements Media { + title: String! + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + media: Media + } + + interface Media { + title: String! + } + + type Book implements Media { + title: String! + isbn: String! + } + + type Movie implements Media { + title: String! @authenticated + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Product.headline' depends on 'Movie.title' through 'Product.headline(title:)' in schema 'A', but does not declare all authorization requirements of 'Movie.title'. Not covered: @authenticated.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.headline", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_ChainDoesNotCoverItsOwnDependency() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + label(price: Int! @require(field: "price")): String + @requiresScopes(scopes: [["finance"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + price(cost: Int! @require(field: "cost")): Int + @requiresScopes(scopes: [["finance"]]) + } + + {{Directives}} + """, + $$""" + # Schema C + type Product @key(fields: "id") { + id: ID! + cost: Int @requiresScopes(scopes: [["finance", "internal"]]) + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Product.price' depends on 'Product.cost' through 'Product.price(cost:)' in schema 'B', but does not declare all authorization requirements of 'Product.cost'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.price", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_LookupKeyIsNotCoveredByAServedField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { products: [Product] } + + type Product @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { productById(id: ID!): Product @lookup } + + type Product @key(fields: "id") { + id: ID! + name: String + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Product.name' depends on 'Product.id' through 'Query.productById' in schema 'B', but does not declare all authorization requirements of 'Product.id'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Product.name", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_UnionLookupKeyIsNotCoveredByAServedField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { dogs: [Dog] } + + type Dog @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { animalById(id: ID!): Animal @lookup } + + union Animal = Dog | Cat + + type Dog @key(fields: "id") { + id: ID! + name: String + } + + type Cat @key(fields: "id") { + id: ID! + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Dog.name' depends on 'Dog.id' through 'Query.animalById' in schema 'B', but does not declare all authorization requirements of 'Dog.id'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Dog.name", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_UnionLookupKeyIsCoveredByTheServedFields() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { dogs: [Dog] } + + type Dog @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { animalById(id: ID!): Animal @lookup } + + union Animal = Dog | Cat + + type Dog @key(fields: "id") { + id: ID! + name: String @requiresScopes(scopes: [["admin"]]) + } + + type Cat @key(fields: "id") { + id: ID! + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_InterfaceLookupKeyIsNotCoveredByAServedField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { dogs: [Dog] } + + type Dog @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { animalById(id: ID!): Animal @lookup } + + interface Animal { + id: ID! + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + name: String + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Dog.name' depends on 'Dog.id' through 'Query.animalById' in schema 'B', but does not declare all authorization requirements of 'Dog.id'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Dog.name", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_InterfaceLookupKeyIsCoveredByTheServedFields() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { dogs: [Dog] } + + type Dog @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { animalById(id: ID!): Animal @lookup } + + interface Animal { + id: ID! + } + + type Dog implements Animal @key(fields: "id") { + id: ID! + name: String @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_UnionLookupKeyChoiceStartsWithABranchForAnotherMember() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { dogs: [Dog] cats: [Cat] } + + type Dog @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + type Cat @key(fields: "id") { + id: ID! + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { animalById(key: ID! @is(field: ".id | .id")): Animal @lookup } + + union Animal = Dog | Cat + + type Dog @key(fields: "id") { + id: ID! + name: String + } + + type Cat @key(fields: "id") { + id: ID! + age: Int + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Dog.name' depends on 'Dog.id' through 'Query.animalById' in schema 'B', but does not declare all authorization requirements of 'Dog.id'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Dog.name", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_NestedUnionLookupKeyChoiceProtectsALaterMember() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { dogs: [Dog] cats: [Cat] } + + type Dog @key(fields: "id") { + id: ID! + } + + type Cat @key(fields: "id") { + id: ID! @requiresScopes(scopes: [["admin"]]) + } + + {{Directives}} + """, + $$""" + # Schema B + type Query { + animalByKey(key: AnimalKey! @is(field: "{ id: .id | .id }")): Animal @lookup + } + + input AnimalKey { id: ID! } + + union Animal = Dog | Cat + + type Dog @key(fields: "id") { + id: ID! + name: String + } + + type Cat @key(fields: "id") { + id: ID! + age: Int + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The field 'Cat.age' depends on 'Cat.id' through 'Query.animalByKey' in schema 'B', but does not declare all authorization requirements of 'Cat.id'. Not covered: @requiresScopes.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Cat.age", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Compose_Should_Fail_When_NodeLookupInheritsAProtectedImplementorIdOntoASiblingImplementor() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + $$""" + type Query { node(id: ID!): Node @lookup } + + interface Node { + id: ID! + } + + type Article implements Node @key(fields: "id") @authenticated { + id: ID! + } + + type Video implements Node @key(fields: "id") { + id: ID! + } + + {{Directives}} + """), + new SourceSchemaText( + "B", + $$""" + type Query { + articleById(id: ID!): Article @lookup + videoById(id: ID!): Video @lookup + } + + type Article @key(fields: "id") { + id: ID! + title: String + } + + type Video @key(fields: "id") { + id: ID! + duration: Int + } + + {{Directives}} + """) + ], + new SchemaComposerOptions(), + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsFailure); + log.Select(e => e.ToString()).MatchInlineSnapshots( + [ + """ + { + "message": "The member 'Article' is marked with @authenticated in the source schemas 'A' but not in the source schemas 'B'. The composed member requires authentication.", + "code": "AUTHENTICATED_MISMATCH", + "severity": "Warning", + "coordinate": "Article", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The member 'Node.id' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from Article.id -> Node.id in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "Node.id", + "extensions": {} + } + """, + """ + { + "message": "The member 'Video.id' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from Article.id -> Node.id -> Video.id in the source schemas 'A'.", + "code": "AUTHORIZATION_INHERITED", + "severity": "Warning", + "coordinate": "Video.id", + "extensions": {} + } + """, + """ + { + "message": "The field 'Video.duration' depends on 'Video.id' through 'Query.videoById' in schema 'B', but does not declare all authorization requirements of 'Video.id'. Not covered: @authenticated.", + "code": "AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING", + "severity": "Error", + "coordinate": "Video.duration", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Compose_Should_Succeed_When_LookupSchemaServesAFieldOfAnAuthenticatedEntity() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer(ProductSchemas("@authenticated"), new SchemaComposerOptions(), log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsSuccess); + log.Select(e => e.ToString()).MatchInlineSnapshots( + [ + """ + { + "message": "The member 'Product' is marked with @authenticated in the source schemas 'A' but not in the source schemas 'B'. The composed member requires authentication.", + "code": "AUTHENTICATED_MISMATCH", + "severity": "Warning", + "coordinate": "Product", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Compose_Should_Succeed_When_LookupSchemaServesAFieldOfAnUnprotectedEntity() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer(ProductSchemas(""), new SchemaComposerOptions(), log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsSuccess); + Assert.True(log.IsEmpty); + } + + private static SourceSchemaText[] ProductSchemas([StringSyntax("graphql")] string authorization) + { + return + [ + new SourceSchemaText( + "A", + $$""" + type Query { products: [Product] } + + type Product @key(fields: "id") {{authorization}} { + id: ID! + } + + {{Directives}} + """), + new SourceSchemaText( + "B", + $$""" + type Query { productById(id: ID!): Product @lookup } + + type Product @key(fields: "id") { + id: ID! + reviews: [String] + } + + {{Directives}} + """) + ]; + } + + private static string[] RequireSchemas( + [StringSyntax("graphql")] string requiring, + [StringSyntax("graphql")] string dependency) + { + return + [ + $$""" + # Schema A + type Product @key(fields: "id") { + id: ID! + delivery(weight: Int! @require(field: "weight")): Int {{requiring}} + } + + {{Directives}} + """, + $$""" + # Schema B + type Product @key(fields: "id") { + id: ID! + weight: Int {{dependency}} + } + + {{Directives}} + """ + ]; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/AuthorizationDirectiveRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/AuthorizationDirectiveRuleTests.cs new file mode 100644 index 00000000000..702094337e4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/AuthorizationDirectiveRuleTests.cs @@ -0,0 +1,208 @@ +namespace HotChocolate.Fusion.PreMergeValidationRules; + +public sealed class AuthorizationDirectiveRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new AuthorizationDirectiveRule(); + + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + private const string PolicyDirective = + """ + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Validate_Should_Succeed_When_AllSourcesMarkAuthenticated() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query @authenticated { field: String @authenticated } + + {{Directives}} + """, + $$""" + # Schema B + type Query @authenticated { field: String @authenticated } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_NoSourceMarksAuthenticated() + { + // arrange & act & assert + AssertValid( + [ + $$""" + # Schema A + type Query { field: String } + + {{Directives}} + """, + $$""" + # Schema B + type Query { field: String } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_WarnAuthenticatedMismatch_When_SourcesDisagreeOnField() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { field: String @authenticated } + + {{Directives}} + """, + $$""" + # Schema B + type Query { field: String } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The member 'Query.field' is marked with @authenticated in the source schemas 'A' but not in the source schemas 'B'. The composed member requires authentication.", + "code": "AUTHENTICATED_MISMATCH", + "severity": "Warning", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_WarnAuthenticatedMismatch_When_SourcesDisagreeOnType() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { field: String } + + {{Directives}} + """, + $$""" + # Schema B + type Query @authenticated { field: String } + + {{Directives}} + """, + $$""" + # Schema C + type Query { field: String } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The member 'Query' is marked with @authenticated in the source schemas 'B' but not in the source schemas 'A', 'C'. The composed member requires authentication.", + "code": "AUTHENTICATED_MISMATCH", + "severity": "Warning", + "coordinate": "Query", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_WarnGroupCountExceeded_When_ScopeProductExceedsThreshold() + { + // arrange + var groupsA = string.Join(", ", Enumerable.Range(0, 9).Select(i => $"[\"a{i}\"]")); + var groupsB = string.Join(", ", Enumerable.Range(0, 8).Select(i => $"[\"b{i}\"]")); + + // act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { field: String @requiresScopes(scopes: [{{groupsA}}]) } + + {{Directives}} + """, + $$""" + # Schema B + type Query { field: String @requiresScopes(scopes: [{{groupsB}}]) } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The merged scopes requirement of 'Query.field' has 72 alternative groups, which exceeds the threshold of 64.", + "code": "AUTHORIZATION_GROUP_COUNT_EXCEEDED", + "severity": "Warning", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_WarnGroupCountExceeded_When_PolicyProductExceedsThreshold() + { + // arrange + var groupsA = string.Join(", ", Enumerable.Range(0, 9).Select(i => $"[\"a{i}\"]")); + var groupsB = string.Join(", ", Enumerable.Range(0, 8).Select(i => $"[\"b{i}\"]")); + + // act & assert + AssertInvalid( + [ + $$""" + # Schema A + type Query { field: String @policy(policies: [{{groupsA}}]) } + + {{PolicyDirective}} + """, + $$""" + # Schema B + type Query { field: String @policy(policies: [{{groupsB}}]) } + + {{PolicyDirective}} + """ + ], + [ + """ + { + "message": "The merged policies requirement of 'Query.field' has 72 alternative groups, which exceeds the threshold of 64.", + "code": "AUTHORIZATION_GROUP_COUNT_EXCEEDED", + "severity": "Warning", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs index c06f4255e33..f079a0c3e3d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs @@ -50,6 +50,41 @@ directive @fusion__execution( """); } + [Fact] + public void Compose_Should_WarnAboutInheritedAuthorization_When_ImplementationFieldIsProtected() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + """ + type Query { search: SearchResult } + + interface SearchResult { title: String } + + type Article implements SearchResult { title: String @authenticated } + + type Video implements SearchResult { title: String } + """) + ], + new SchemaComposerOptions(), + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsSuccess); + Assert.Equal( + [ + "AUTHORIZATION_INHERITED:SearchResult.title", + "AUTHORIZATION_INHERITED:Video.title" + ], + log.Select(e => $"{e.Code}:{e.Coordinate}")); + } + [Theory] [InlineData(NodeResolution.Gateway)] [InlineData(NodeResolution.SourceSchema)] @@ -1109,6 +1144,8 @@ public void SourceSchemaRules_Should_ContainTheRegisteredRuleSet() // assert Assert.Equal( [ + "AuthorizationDirectiveArgumentRule", + "AuthorizationOnInterfaceObjectRule", "CostDirectiveDefinitionRule", "DisallowedInaccessibleElementsRule", "ExternalOnInterfaceRule", @@ -1163,6 +1200,7 @@ public void PreMergeRules_Should_ContainTheRegisteredRuleSet() // assert Assert.Equal( [ + "AuthorizationDirectiveRule", "EnumValuesMismatchRule", "ExternalArgumentDefaultMismatchRule", "ExternalArgumentMissingRule", @@ -1196,6 +1234,8 @@ public void PostMergeRules_Should_ContainTheRegisteredRuleSet() // assert Assert.Equal( [ + "AuthorizationInheritedRule", + "AuthorizationTransitiveRequirementsRule", "EmptyMergedEnumTypeRule", "EmptyMergedInputObjectTypeRule", "EmptyMergedInterfaceTypeRule", diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizationDirective.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizationDirective.Tests.cs new file mode 100644 index 00000000000..2a8154c08d9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizationDirective.Tests.cs @@ -0,0 +1,298 @@ +namespace HotChocolate.Fusion; + +public sealed class SourceSchemaMergerAuthorizationDirectiveTests : SourceSchemaMergerTestBase +{ + private const string AuthorizationDirectives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Merge_Should_EmitFusionAuthorization_When_MembersCarrySourceDirectives() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query @authenticated { + field: Int @requiresScopes(scopes: [["read"]]) + interfaceField: FooInterface + enumField: FooEnum + scalarField: FooScalar + } + + interface FooInterface @policy(policies: [["Admin"]]) { + id: Int + } + + enum FooEnum @authenticated { + ONE + } + + scalar FooScalar @requiresScopes(scopes: [["scalar"]]) + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__authorization(authenticated: true) @fusion__type(schema: A) { + enumField: FooEnum + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + field: Int + @fusion__authorization(authenticated: true, scopes: [["read"]]) + @fusion__field(schema: A) + interfaceField: FooInterface + @fusion__authorization(authenticated: true, policies: [["Admin"]]) + @fusion__field(schema: A) + scalarField: FooScalar + @fusion__authorization(authenticated: true, scopes: [["scalar"]]) + @fusion__field(schema: A) + } + + interface FooInterface + @fusion__authorization(policies: [["Admin"]]) + @fusion__type(schema: A) { + id: Int + @fusion__authorization(policies: [["Admin"]]) + @fusion__field(schema: A) + } + + enum FooEnum + @fusion__authorization(authenticated: true) + @fusion__type(schema: A) { + ONE @fusion__enumValue(schema: A) + } + + scalar FooScalar + @fusion__authorization(scopes: [["scalar"]]) + @fusion__type(schema: A) + """); + } + + [Fact] + public void Merge_Should_MarkAuthenticated_When_AnySourceMarksTheMember() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + field: Int @authenticated + other: Int + } + + {{AuthorizationDirectives}} + """, + $$""" + # Schema B + type Query { + field: Int + other: Int + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + field: Int + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + @fusion__field(schema: B) + other: Int @fusion__field(schema: A) @fusion__field(schema: B) + } + """); + } + + [Fact] + public void Merge_Should_BuildCartesianProductOfScopes_When_MultipleSourcesRequireScopes() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + field: Int @requiresScopes(scopes: [["a"], ["b"]]) + } + + {{AuthorizationDirectives}} + """, + $$""" + # Schema B + type Query { + field: Int @requiresScopes(scopes: [["c"], ["d"]]) + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + field: Int + @fusion__authorization( + scopes: [["a", "c"], ["a", "d"], ["b", "c"], ["b", "d"]] + ) + @fusion__field(schema: A) + @fusion__field(schema: B) + } + """); + } + + [Fact] + public void Merge_Should_WriteCanonicalPolicies_When_GroupsAreUnsortedDuplicatedOrSupersets() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + field: Int @policy(policies: [["z", "a"], ["a"], ["m", "b"], ["b", "m"]]) + } + + {{AuthorizationDirectives}} + """, + $$""" + # Schema B + type Query { + field: Int @policy(policies: [["x"], ["a", "b"]]) + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + field: Int + @fusion__authorization(policies: [["a", "b"], ["a", "x"], ["b", "m", "x"]]) + @fusion__field(schema: A) + @fusion__field(schema: B) + } + """); + } + + [Fact] + public void Merge_Should_CombineAuthenticatedScopesAndPolicies_When_SourcesDeclareDifferentParts() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + field: Int @authenticated @requiresScopes(scopes: [["read"]]) + } + + {{AuthorizationDirectives}} + """, + $$""" + # Schema B + type Query { + field: Int @policy(policies: [["Admin"]]) + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + field: Int + @fusion__authorization( + authenticated: true + scopes: [["read"]] + policies: [["Admin"]] + ) + @fusion__field(schema: A) + @fusion__field(schema: B) + } + """); + } + + [Fact] + public void Merge_Should_DropAuthorize_When_SourceSchemaUsesAuthorize() + { + // arrange & act & assert + AssertMatches( + [ + """ + # Schema A + type Query @authorize(policy: "PolicyA1") { + field: Int @authorize(policy: "PolicyA2") + } + + directive @authorize(policy: String) repeatable on FIELD_DEFINITION | OBJECT + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + field: Int @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_EmitCanonicalGroups_When_ArgumentIsASingleString() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + scoped: Int @requiresScopes(scopes: "read") + policed: Int @policy(policies: "p") + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + policed: Int + @fusion__authorization(policies: [["p"]]) + @fusion__field(schema: A) + scoped: Int + @fusion__authorization(scopes: [["read"]]) + @fusion__field(schema: A) + } + """); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizationInheritance.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizationInheritance.Tests.cs new file mode 100644 index 00000000000..5c284e622b2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizationInheritance.Tests.cs @@ -0,0 +1,754 @@ +namespace HotChocolate.Fusion; + +public sealed class SourceSchemaMergerAuthorizationInheritanceTests : SourceSchemaMergerTestBase +{ + private const string AuthorizationDirectives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Merge_Should_InheritInterfaceFieldRequirement_When_ImplementationIsUnannotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + node: Node + } + + interface Node { + id: ID! @requiresScopes(scopes: [["read"]]) + } + + type User implements Node { + id: ID! + name: String + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + node: Node @fusion__field(schema: A) + } + + type User implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__authorization(scopes: [["read"]]) @fusion__field(schema: A) + name: String @fusion__field(schema: A) + } + + interface Node @fusion__type(schema: A) { + id: ID! @fusion__authorization(scopes: [["read"]]) @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_CombineInterfaceAndImplementationRequirements_When_BothDeclareScopes() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + node: Node + } + + interface Node { + id: ID! @requiresScopes(scopes: [["read"], ["audit"]]) + } + + type User implements Node { + id: ID! @requiresScopes(scopes: [["admin"]]) @authenticated + name: String + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + node: Node @fusion__field(schema: A) + } + + type User implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! + @fusion__authorization( + authenticated: true + scopes: [["admin", "audit"], ["admin", "read"]] + ) + @fusion__field(schema: A) + name: String @fusion__field(schema: A) + } + + interface Node @fusion__type(schema: A) { + id: ID! + @fusion__authorization( + authenticated: true + scopes: [["admin", "audit"], ["admin", "read"]] + ) + @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_FlattenTypeLevelRequirementOntoEveryField_When_ObjectIsAnnotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + me: Account + } + + type Account @requiresScopes(scopes: [["account"]]) { + id: ID! + email: String @policy(policies: [["Owner"]]) + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + me: Account + @fusion__authorization(scopes: [["account"]]) + @fusion__field(schema: A) + } + + type Account + @fusion__authorization(scopes: [["account"]]) + @fusion__type(schema: A) { + email: String + @fusion__authorization(scopes: [["account"]], policies: [["Owner"]]) + @fusion__field(schema: A) + id: ID! + @fusion__authorization(scopes: [["account"]]) + @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_FenceSiblingImplementation_When_OneImplementationIsProtected() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + search: [SearchResult!]! + video: Video + } + + interface SearchResult { + title: String + } + + type Article implements SearchResult { + title: String @authenticated + body: String + } + + type Video implements SearchResult { + title: String + url: String + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + search: [SearchResult!]! @fusion__field(schema: A) + video: Video @fusion__field(schema: A) + } + + type Article implements SearchResult + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "SearchResult") { + body: String @fusion__field(schema: A) + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + + type Video implements SearchResult + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "SearchResult") { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + url: String @fusion__field(schema: A) + } + + interface SearchResult @fusion__type(schema: A) { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_FoldTypeRequirementThroughFieldsOnly_When_ImplementationIsAnnotatedOnTheType() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + search: [SearchResult!]! + video: Video + } + + interface SearchResult { + title: String + } + + type Article implements SearchResult @authenticated { + title: String + } + + type Video implements SearchResult { + title: String + url: String + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + search: [SearchResult!]! @fusion__field(schema: A) + video: Video @fusion__field(schema: A) + } + + type Article implements SearchResult + @fusion__authorization(authenticated: true) + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "SearchResult") { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + + type Video implements SearchResult + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "SearchResult") { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + url: String @fusion__field(schema: A) + } + + interface SearchResult @fusion__type(schema: A) { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_InheritInterfaceTypeRequirement_When_ImplementationIsUnannotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + video: Video + } + + interface SearchResult @authenticated { + title: String + } + + type Video implements SearchResult { + title: String + url: String + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + video: Video + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + + type Video implements SearchResult + @fusion__authorization(authenticated: true) + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "SearchResult") { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + url: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + + interface SearchResult + @fusion__authorization(authenticated: true) + @fusion__type(schema: A) { + title: String + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_ProtectFieldsReturningTheType_When_NamedTypesAreAnnotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + secret: Secret + level: Level + token: Token + thing: Thing + many: [Secret!]! + } + + type Secret @authenticated { + id: ID! + } + + enum Level @requiresScopes(scopes: [["level"]]) { + LOW + } + + scalar Token @policy(policies: [["TokenPolicy"]]) + + interface Thing @authenticated @requiresScopes(scopes: [["thing"]]) { + id: ID! + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + level: Level + @fusion__authorization(scopes: [["level"]]) + @fusion__field(schema: A) + many: [Secret!]! + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + secret: Secret + @fusion__authorization(authenticated: true) + @fusion__field(schema: A) + thing: Thing + @fusion__authorization(authenticated: true, scopes: [["thing"]]) + @fusion__field(schema: A) + token: Token + @fusion__authorization(policies: [["TokenPolicy"]]) + @fusion__field(schema: A) + } + + type Secret @fusion__authorization(authenticated: true) @fusion__type(schema: A) { + id: ID! @fusion__authorization(authenticated: true) @fusion__field(schema: A) + } + + interface Thing + @fusion__authorization(authenticated: true, scopes: [["thing"]]) + @fusion__type(schema: A) { + id: ID! + @fusion__authorization(authenticated: true, scopes: [["thing"]]) + @fusion__field(schema: A) + } + + enum Level @fusion__authorization(scopes: [["level"]]) @fusion__type(schema: A) { + LOW @fusion__enumValue(schema: A) + } + + scalar Token + @fusion__authorization(policies: [["TokenPolicy"]]) + @fusion__type(schema: A) + """); + } + + [Fact] + public void Merge_Should_ExpandInterfaceObjectFieldRequirement_When_StandInFieldIsAnnotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + media: Media + } + + interface Media @key(fields: "id") { + id: ID! + title: String + } + + type Book implements Media @key(fields: "id") { + id: ID! + title: String + pages: Int @requiresScopes(scopes: [["pages"]]) + } + + {{AuthorizationDirectives}} + """, + $$""" + # Schema B + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [String!]! @requiresScopes(scopes: [["reviews"]]) + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + media: Media @fusion__field(schema: A) + } + + type Book implements Media + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Media") { + id: ID! @fusion__field(schema: A) + pages: Int + @fusion__authorization(scopes: [["pages"]]) + @fusion__field(schema: A) + reviews: [String!]! + @fusion__authorization(scopes: [["reviews"]]) + @fusion__field(schema: B) + title: String @fusion__field(schema: A) + } + + interface Media + @fusion__type(schema: A) + @fusion__type(schema: B) + @fusion__interfaceObject(schema: B) { + id: ID! @fusion__field(schema: A) @fusion__field(schema: B) + reviews: [String!]! + @fusion__authorization(scopes: [["reviews"]]) + @fusion__field(schema: B) + title: String @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_InheritThroughInterfaceChain_When_InterfaceImplementsInterface() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + node: Node + } + + interface Node { + id: ID! @requiresScopes(scopes: [["node"]]) + } + + interface Entity implements Node { + id: ID! + name: String @policy(policies: [["EntityPolicy"]]) + } + + type User implements Entity & Node { + id: ID! + name: String + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + node: Node @fusion__field(schema: A) + } + + type User implements Entity & Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Entity") + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__authorization(scopes: [["node"]]) @fusion__field(schema: A) + name: String + @fusion__authorization(policies: [["EntityPolicy"]]) + @fusion__field(schema: A) + } + + interface Entity implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__authorization(scopes: [["node"]]) @fusion__field(schema: A) + name: String + @fusion__authorization(policies: [["EntityPolicy"]]) + @fusion__field(schema: A) + } + + interface Node @fusion__type(schema: A) { + id: ID! @fusion__authorization(scopes: [["node"]]) @fusion__field(schema: A) + } + """); + } + + [Fact] + public void Merge_Should_CombineRequirementsOfAllSourceSchemas_When_InterfaceAndImplementationAreSplit() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + node: Node + } + + interface Node { + id: ID! @requiresScopes(scopes: [["a"]]) + } + + {{AuthorizationDirectives}} + """, + $$""" + # Schema B + type Query { + user: User + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! @requiresScopes(scopes: [["b"], ["c"]]) + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) @fusion__type(schema: B) { + node: Node @fusion__field(schema: A) + user: User @fusion__field(schema: B) + } + + type User implements Node + @fusion__type(schema: B) + @fusion__implements(schema: B, interface: "Node") { + id: ID! + @fusion__authorization(scopes: [["a", "b"], ["a", "c"]]) + @fusion__field(schema: B) + } + + interface Node @fusion__type(schema: A) @fusion__type(schema: B) { + id: ID! + @fusion__authorization(scopes: [["a", "b"], ["a", "c"]]) + @fusion__field(schema: A) + @fusion__field(schema: B) + } + """); + } + + [Fact] + public void Merge_Should_NotProtectNodeTypeOrSiblings_When_NodeImplementationIsAnnotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + node(id: ID!): Node @lookup + product(id: ID!): Product @lookup + } + + interface Node { + id: ID! + } + + type User implements Node @authenticated { + id: ID! + } + + type Product implements Node { + id: ID! + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + node(id: ID!): Node @fusion__gateway_field + product(id: ID! @fusion__inputField(schema: A)): Product + @fusion__field(schema: A) + } + + type Product implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") + @fusion__lookup( + schema: A + key: "id" + field: "product(id: ID!): Product" + map: ["id"] + path: null + internal: false + ) { + id: ID! @fusion__authorization(authenticated: true) @fusion__field(schema: A) + } + + type User implements Node + @fusion__authorization(authenticated: true) + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__authorization(authenticated: true) @fusion__field(schema: A) + } + + interface Node + @fusion__type(schema: A) + @fusion__lookup( + schema: A + key: "id" + field: "node(id: ID!): Node" + map: ["id"] + path: null + internal: false + ) { + id: ID! @fusion__authorization(authenticated: true) @fusion__field(schema: A) + } + """, + options => options.EnableGlobalObjectIdentification = true); + } + + [Fact] + public void Merge_Should_KeepNodeFieldRequirement_When_SourceNodeFieldIsAnnotated() + { + // arrange & act & assert + AssertMatches( + [ + $$""" + # Schema A + type Query { + node(id: ID!): Node @lookup @requiresScopes(scopes: [["node"]]) + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + } + + {{AuthorizationDirectives}} + """ + ], + """ + schema { + query: Query + } + + type Query @fusion__type(schema: A) { + node(id: ID!): Node + @fusion__authorization(scopes: [["node"]]) + @fusion__gateway_field + } + + type User implements Node + @fusion__type(schema: A) + @fusion__implements(schema: A, interface: "Node") { + id: ID! @fusion__field(schema: A) + } + + interface Node + @fusion__type(schema: A) + @fusion__lookup( + schema: A + key: "id" + field: "node(id: ID!): Node" + map: ["id"] + path: null + internal: false + ) { + id: ID! @fusion__field(schema: A) + } + """, + options => options.EnableGlobalObjectIdentification = true); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizeDirective.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizeDirective.Tests.cs deleted file mode 100644 index 2d5ae1281b9..00000000000 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizeDirective.Tests.cs +++ /dev/null @@ -1,485 +0,0 @@ -using HotChocolate.Fusion.Definitions; -using HotChocolate.Types.Mutable; - -namespace HotChocolate.Fusion; - -public sealed class SourceSchemaMergerAuthorizeDirectiveTests : SourceSchemaMergerTestBase -{ - // Merge @authorize directives when the definitions match the canonical definition. - [Fact] - public void Merge_AuthorizeDirectives_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "PolicyA1") { - field: Int @authorize(policy: "PolicyA2") - } - - type FooObject @authorize(policy: "PolicyA3") { - field: Int @authorize(policy: "PolicyA4") - } - - interface FooInterface { - field: Int @authorize(policy: "PolicyA5") - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "PolicyB1") { - field: Int @authorize(policy: "PolicyB2") - } - - type FooObject @authorize(policy: "PolicyB3") { - field: Int @authorize(policy: "PolicyB4") - } - - interface FooInterface { - field: Int @authorize(policy: "PolicyB5") - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "PolicyA1") - @authorize(policy: "PolicyB1") - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "PolicyA2") - @authorize(policy: "PolicyB2") - @fusion__field(schema: A) - @fusion__field(schema: B) - } - - type FooObject - @authorize(policy: "PolicyA3") - @authorize(policy: "PolicyB3") - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "PolicyA4") - @authorize(policy: "PolicyB4") - @fusion__field(schema: A) - @fusion__field(schema: B) - } - - interface FooInterface @fusion__type(schema: A) @fusion__type(schema: B) { - field: Int - @authorize(policy: "PolicyA5") - @authorize(policy: "PolicyB5") - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Do not merge @authorize directives when the definitions do not match the canonical definition. - [Fact] - public void Merge_AuthorizeDirectivesNonMatching_MatchesSnapshot() - { - AssertMatches( - [ - """ - # Schema A - type Query @authorize(resource: "ResourceA1") { - field: Int - } - - directive @authorize(resource: String) repeatable on OBJECT - """, - """ - # Schema B - type Query { - field: Int @authorize - } - - directive @authorize on FIELD_DEFINITION - """ - ], - """ - schema { - query: Query - } - - type Query @fusion__type(schema: A) @fusion__type(schema: B) { - field: Int @fusion__field(schema: A) @fusion__field(schema: B) - } - """); - } - - // Merge @authorize directives with the same policy name. - [Fact] - public void Merge_AuthorizeDirectivesSamePolicyName_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "Policy1") { - field: Int @authorize(policy: "Policy2") - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "Policy1") { - field: Int @authorize(policy: "Policy2") - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "Policy1") - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "Policy2") - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives with the same policy name but different apply policy. - [Fact] - public void Merge_AuthorizeDirectivesSamePolicyNameDifferentApplyPolicy_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "Policy1", apply: BEFORE_RESOLVER) { - field: Int @authorize(policy: "Policy2", apply: AFTER_RESOLVER) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "Policy1", apply: AFTER_RESOLVER) { - field: Int @authorize(policy: "Policy2", apply: BEFORE_RESOLVER) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "Policy1") - @authorize(policy: "Policy1", apply: AFTER_RESOLVER) - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "Policy2", apply: AFTER_RESOLVER) - @authorize(policy: "Policy2") - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives where one uses the default apply value and the other explicitly - // specifies BEFORE_RESOLVER. This verifies that omitted apply and apply: BEFORE_RESOLVER are - // treated as equivalent and do not produce duplicates. - [Fact] - public void Merge_AuthorizeDirectivesDefaultApplyEquivalentToBeforeResolver_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "Policy1") { - field: Int - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "Policy1", apply: BEFORE_RESOLVER) { - field: Int - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "Policy1") - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int @fusion__field(schema: A) @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives with the same roles (any order). - [Fact] - public void Merge_AuthorizeDirectivesSameRoles_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(roles: ["Role1", "Role2"]) { - field: Int @authorize(roles: ["Role2", "Role1"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(roles: ["Role2", "Role1"]) { - field: Int @authorize(roles: ["Role1", "Role2"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(roles: ["Role1", "Role2"]) - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(roles: ["Role1", "Role2"]) - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives with the same roles (any order) but different apply policies. - [Fact] - public void Merge_AuthorizeDirectivesSameRolesDifferentApplyPolicy_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(roles: ["Role1", "Role2"], apply: BEFORE_RESOLVER) { - field: Int @authorize(roles: ["Role2", "Role1"], apply: AFTER_RESOLVER) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(roles: ["Role2", "Role1"], apply: AFTER_RESOLVER) { - field: Int @authorize(roles: ["Role1", "Role2"], apply: BEFORE_RESOLVER) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(roles: ["Role1", "Role2"]) - @authorize(roles: ["Role1", "Role2"], apply: AFTER_RESOLVER) - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(roles: ["Role1", "Role2"], apply: AFTER_RESOLVER) - @authorize(roles: ["Role1", "Role2"]) - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives with the same policy but different roles. - [Fact] - public void Merge_AuthorizeDirectivesSamePolicyDifferentRoles_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "Policy1", roles: ["RoleA1", "RoleA2"]) { - field: Int @authorize(policy: "Policy2", roles: ["RoleA1", "RoleA2"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "Policy1", roles: ["RoleB1", "RoleB2"]) { - field: Int @authorize(policy: "Policy2", roles: ["RoleB1", "RoleB2"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "Policy1", roles: ["RoleA1", "RoleA2"]) - @authorize(policy: "Policy1", roles: ["RoleB1", "RoleB2"]) - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "Policy2", roles: ["RoleA1", "RoleA2"]) - @authorize(policy: "Policy2", roles: ["RoleB1", "RoleB2"]) - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives with the same roles (any order) but different policy. - [Fact] - public void Merge_AuthorizeDirectivesSameRolesDifferentPolicy_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "PolicyA1", roles: ["Role1", "Role2"]) { - field: Int @authorize(policy: "PolicyA2", roles: ["Role2", "Role1"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "PolicyB1", roles: ["Role2", "Role1"]) { - field: Int @authorize(policy: "PolicyB2", roles: ["Role1", "Role2"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "PolicyA1", roles: ["Role1", "Role2"]) - @authorize(policy: "PolicyB1", roles: ["Role1", "Role2"]) - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "PolicyA2", roles: ["Role1", "Role2"]) - @authorize(policy: "PolicyB2", roles: ["Role1", "Role2"]) - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - // Merge @authorize directives with the same policy and roles (any order). - [Fact] - public void Merge_AuthorizeDirectivesSamePolicyAndRoles_MatchesSnapshot() - { - AssertMatches( - [ - $$""" - # Schema A - type Query @authorize(policy: "Policy1", roles: ["Role1", "Role2"]) { - field: Int @authorize(policy: "Policy2", roles: ["Role2", "Role1"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """, - $$""" - # Schema B - type Query @authorize(policy: "Policy1", roles: ["Role2", "Role1"]) { - field: Int @authorize(policy: "Policy2", roles: ["Role1", "Role2"]) - } - - {{s_applyPolicyEnum}} - {{s_authorizeDirective}} - """ - ], - """ - schema { - query: Query - } - - type Query - @authorize(policy: "Policy1", roles: ["Role1", "Role2"]) - @fusion__type(schema: A) - @fusion__type(schema: B) { - field: Int - @authorize(policy: "Policy2", roles: ["Role1", "Role2"]) - @fusion__field(schema: A) - @fusion__field(schema: B) - } - """, - modifySchema: s_removeAuthorizeDirective); - } - - private static readonly ApplyPolicyMutableEnumTypeDefinition s_applyPolicyEnum = new(); - - private static readonly AuthorizeMutableDirectiveDefinition s_authorizeDirective - = new(BuiltIns.String.Create(), s_applyPolicyEnum); - - private static readonly Action s_removeAuthorizeDirective - = schema => - { - schema.DirectiveDefinitions.Remove(WellKnownDirectiveNames.Authorize); - schema.Types.Remove(WellKnownTypeNames.ApplyPolicy); - }; -} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaParserTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaParserTests.cs index dfe97236014..1d3ded86c7b 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaParserTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaParserTests.cs @@ -81,6 +81,100 @@ type Product @key(fields: "id") { Assert.IsNotType(requiresDefinition); } + [Fact] + public void Parse_Should_BindAuthorizationDirectives_When_SourceSchemaDeclaresNone() + { + // arrange + var sourceSchemaText = + new SourceSchemaText( + "A", + // lang=graphql + """ + type Query @authenticated { + a: String @requiresScopes(scopes: [["read"]]) + b: String @policy(policies: [["admin"]]) + } + """); + var log = new CompositionLog(); + var parser = new SourceSchemaParser(sourceSchemaText, log); + + // act + var result = parser.Parse(); + + // assert + Assert.True(result.IsSuccess); + Assert.Empty(log); + Snapshot.Create() + .Add( + string.Join( + "\n\n", + result.Value.DirectiveDefinitions + .AsEnumerable() + .Where(d => d.Name is "authenticated" or "requiresScopes" or "policy") + .OrderBy(d => d.Name, StringComparer.Ordinal) + .Select(d => d.ToString())), + "Directive definitions", "graphql") + .MatchMarkdownSnapshot(); + } + + [Fact] + public void Parse_Should_KeepCanonicalDefinition_When_SourceSchemaDeclaresCompatibleOne() + { + // arrange + var sourceSchemaText = + new SourceSchemaText( + "A", + // lang=graphql + """ + type Query { + a: String @requiresScopes(scopes: [["read"]]) + } + + directive @requiresScopes(scopes: [[Scope!]!]!) on FIELD_DEFINITION + + scalar Scope + """); + var log = new CompositionLog(); + var parser = new SourceSchemaParser(sourceSchemaText, log); + + // act + var result = parser.Parse(); + + // assert + Assert.True(result.IsSuccess); + Assert.Equal( + "[[String!]!]!", + result.Value.DirectiveDefinitions["requiresScopes"].Arguments["scopes"].Type.ToString()); + } + + [Fact] + public void Parse_Should_KeepCanonicalDefinition_When_SourceSchemaDeclaresIncompatibleOne() + { + // arrange + var sourceSchemaText = + new SourceSchemaText( + "A", + // lang=graphql + """ + type Query { + a: String @requiresScopes(scopes: [["read"]]) + } + + directive @requiresScopes(roles: [[String!]!]!) on FIELD_DEFINITION + """); + var log = new CompositionLog(); + var parser = new SourceSchemaParser(sourceSchemaText, log); + + // act + var result = parser.Parse(); + + // assert + Assert.True(result.IsSuccess); + Assert.Equal( + ["scopes"], + result.Value.DirectiveDefinitions["requiresScopes"].Arguments.AsEnumerable().Select(a => a.Name).ToArray()); + } + [Fact] public void Parse_NonFederationRequires_ReportsUndefinedDirective() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/AuthorizationDirectiveArgumentRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/AuthorizationDirectiveArgumentRuleTests.cs new file mode 100644 index 00000000000..ba0984f6999 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/AuthorizationDirectiveArgumentRuleTests.cs @@ -0,0 +1,285 @@ +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; + +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +public sealed class AuthorizationDirectiveArgumentRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new AuthorizationDirectiveArgumentRule(); + + private const string Directives = + """ + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Validate_Should_Succeed_When_ArgumentsUseCanonicalAndCoercibleShapes() + { + // arrange & act & assert + AssertValid( + [ + $$""" + type Query { + a: Int @requiresScopes(scopes: [["read", "write"], ["admin"]]) + b: Int @requiresScopes(scopes: "read") + c: Int @requiresScopes(scopes: ["read", ["write"]]) + d: Int @policy(policies: "p") + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_ScopesIsNotAStringOrList() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + type Query { + field: Int @requiresScopes(scopes: 1) + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query.field' in schema 'A' 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 1.", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_OuterListIsEmpty() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + type Query { + field: Foo @requiresScopes(scopes: []) + } + + type Foo @policy(policies: []) { + id: Int + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query.field' in schema 'A' 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 [].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The 'policies' argument of the @policy directive on 'Foo' in schema 'A' 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 [].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Foo", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_InnerGroupIsEmpty() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + type Query { + field: Foo @requiresScopes(scopes: [["read"], []]) + } + + type Foo @policy(policies: [[]]) { + id: Int + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query.field' in schema 'A' 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 [[\"read\"], []].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The 'policies' argument of the @policy directive on 'Foo' in schema 'A' 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 [[]].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Foo", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_StringFactorIsBlank() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + type Query { + field: Foo @requiresScopes(scopes: [["read", " "]]) + } + + type Foo @policy(policies: "") { + id: Int + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query.field' in schema 'A' 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 [[\"read\", \" \"]].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The 'policies' argument of the @policy directive on 'Foo' in schema 'A' 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 \"\".", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Foo", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_ArgumentIsMissingOrNullOnTypes() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + type Query @requiresScopes { + field: Foo + } + + scalar Foo @policy(policies: null) + + enum Bar @policy(policies: [[1]]) { + ONE + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query' in schema 'A' 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 undefined.", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The 'policies' argument of the @policy directive on 'Foo' in schema 'A' 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 null.", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Foo", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The 'policies' argument of the @policy directive on 'Bar' in schema 'A' 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 [[1]].", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Bar", + "schema": "A", + "extensions": {} + } + """ + ]); + } + + [Fact] + public void Compose_Should_Fail_When_ScopesArgumentIsMissing() + { + // arrange + var log = new CompositionLog(); + var composer = new SchemaComposer( + [ + new SourceSchemaText( + "A", + $$""" + type Query { + field: Int @requiresScopes + } + + {{Directives}} + """) + ], + new SchemaComposerOptions(), + log); + + // act + var result = composer.Compose(); + + // assert + Assert.True(result.IsFailure); + log.Select(e => e.ToString()).MatchInlineSnapshots( + [ + """ + { + "message": "The 'scopes' argument of the @requiresScopes directive on 'Query.field' in schema 'A' 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 undefined.", + "code": "INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT", + "severity": "Error", + "coordinate": "Query.field", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/AuthorizationOnInterfaceObjectRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/AuthorizationOnInterfaceObjectRuleTests.cs new file mode 100644 index 00000000000..09a99e13a73 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaValidationRules/AuthorizationOnInterfaceObjectRuleTests.cs @@ -0,0 +1,108 @@ +namespace HotChocolate.Fusion.SourceSchemaValidationRules; + +public sealed class AuthorizationOnInterfaceObjectRuleTests : RuleTestBase +{ + protected override object Rule { get; } = new AuthorizationOnInterfaceObjectRule(); + + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + [Fact] + public void Validate_Should_Succeed_When_OnlyTheFieldsOfTheInterfaceObjectAreAnnotated() + { + // arrange & act & assert + AssertValid( + [ + $$""" + type Query { media: Media } + + type Media @interfaceObject @key(fields: "id") { + id: ID! + reviews: [String!]! @authenticated @requiresScopes(scopes: [["read"]]) + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Succeed_When_AnOrdinaryObjectTypeIsAnnotated() + { + // arrange & act & assert + AssertValid( + [ + $$""" + type Query { media: Media } + + type Media @authenticated @key(fields: "id") { + id: ID! + } + + {{Directives}} + """ + ]); + } + + [Fact] + public void Validate_Should_Fail_When_TheInterfaceObjectTypeIsAnnotated() + { + // arrange & act & assert + AssertInvalid( + [ + $$""" + type Query { media: Media } + + type Media @interfaceObject @key(fields: "id") + @authenticated @requiresScopes(scopes: [["read"]]) @policy(policies: [["p"]]) { + id: ID! + } + + {{Directives}} + """ + ], + [ + """ + { + "message": "The @interfaceObject type 'Media' in schema 'A' cannot be annotated with @authenticated. Annotate its fields instead.", + "code": "AUTHORIZATION_ON_INTERFACE_OBJECT", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The @interfaceObject type 'Media' in schema 'A' cannot be annotated with @requiresScopes. Annotate its fields instead.", + "code": "AUTHORIZATION_ON_INTERFACE_OBJECT", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "A", + "extensions": {} + } + """, + """ + { + "message": "The @interfaceObject type 'Media' in schema 'A' cannot be annotated with @policy. Annotate its fields instead.", + "code": "AUTHORIZATION_ON_INTERFACE_OBJECT", + "severity": "Error", + "coordinate": "Media", + "member": "Media", + "schema": "A", + "extensions": {} + } + """ + ]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql index ace89229316..b73e3c405c6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaMergerTests.Merge_FourNamedSchemas_AddsFusionDefinitions.graphql @@ -28,6 +28,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet +"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." +directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] +) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaParserTests.Parse_Should_BindAuthorizationDirectives_When_SourceSchemaDeclaresNone.md b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaParserTests.Parse_Should_BindAuthorizationDirectives_When_SourceSchemaDeclaresNone.md new file mode 100644 index 00000000000..e522d0506f3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaParserTests.Parse_Should_BindAuthorizationDirectives_When_SourceSchemaDeclaresNone.md @@ -0,0 +1,23 @@ +# Parse_Should_BindAuthorizationDirectives_When_SourceSchemaDeclaresNone + +```graphql +"The @authenticated directive requires the client to be authenticated." +directive @authenticated on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | INTERFACE + | ENUM + +"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 + +"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/test/Fusion.Diagnostics.Tests/FusionActivityExecutionDiagnosticListenerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/FusionActivityExecutionDiagnosticListenerTests.cs index f1608f67706..cc6844c8ca9 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/FusionActivityExecutionDiagnosticListenerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Diagnostics.Tests/FusionActivityExecutionDiagnosticListenerTests.cs @@ -3,6 +3,7 @@ using HotChocolate.Diagnostics; using HotChocolate.Execution; using HotChocolate.Features; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Rewriters; using HotChocolate.Fusion.Planning; @@ -1339,7 +1340,10 @@ private static OperationPlan PlanOperationForDiagnostics( var rewritten = rewriter.RewriteDocument(operationDocument, operationName: null); var operation = rewritten.Definitions.OfType().First(); var compiler = new OperationCompiler(schema, pool); - var planner = new OperationPlanner(schema, compiler); + var planner = new OperationPlanner( + schema, + compiler, + new PolicyResolver(new BuiltInPolicyProvider(), [])); return planner.CreatePlan("123456789101112", "123456789101112", "123456789101112", operation); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthenticationSchemeResolverTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthenticationSchemeResolverTests.cs new file mode 100644 index 00000000000..bfbaedad48f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthenticationSchemeResolverTests.cs @@ -0,0 +1,168 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +public class AuthenticationSchemeResolverTests +{ + [Fact] + public async Task GetChallengeAsync_Should_AdvertiseProtocolChallenge_When_HandlerFixesOne() + { + // arrange + var schemeResolver = new AuthenticationSchemeResolver( + new FusionAuthorizationOptions(), + new TestAuthenticationSchemeLookup("MyJwt") + { + Challenges = ImmutableDictionary.Empty.Add("MyJwt", "Bearer") + }); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Equal("Bearer", challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_AdvertiseChallengeOnce_When_RegistrationsShareIt() + { + // arrange + var schemeResolver = new AuthenticationSchemeResolver( + new FusionAuthorizationOptions(), + new TestAuthenticationSchemeLookup("JwtA", "JwtB") + { + Challenges = ImmutableDictionary.Empty.Add("JwtA", "Bearer").Add("JwtB", "Bearer") + }); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Equal("Bearer", challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_AdvertiseChallengesInOrdinalOrder_When_RegistrationsDiffer() + { + // arrange + var schemeResolver = new AuthenticationSchemeResolver( + new FusionAuthorizationOptions(), + new TestAuthenticationSchemeLookup("Windows", "MyJwt") + { + Challenges = ImmutableDictionary.Empty + .Add("Windows", "Negotiate") + .Add("MyJwt", "Bearer") + }); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Equal("Bearer, Negotiate", challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_AdvertiseMappedChallenge_When_HandlerFixesNone() + { + // arrange + var options = new FusionAuthorizationOptions + { + SchemeChallenges = ImmutableDictionary.Empty.Add("Cookies", "Cookie") + }; + var schemeResolver = new AuthenticationSchemeResolver(options, new TestAuthenticationSchemeLookup("Cookies")); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Equal("Cookie", challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_ReturnNull_When_HandlerFixesNoneAndNoEntryIsMapped() + { + // arrange + var schemeResolver = new AuthenticationSchemeResolver( + new FusionAuthorizationOptions(), + new TestAuthenticationSchemeLookup("Cookies")); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Null(challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_AdvertiseOnlyListedSchemes_When_SchemesAreSet() + { + // arrange + var options = new FusionAuthorizationOptions + { + Schemes = ImmutableArray.Create("Windows"), + SchemeChallenges = ImmutableDictionary.Empty.Add("Windows", "Negotiate") + }; + var schemeResolver = new AuthenticationSchemeResolver( + options, + new TestAuthenticationSchemeLookup("MyJwt", "Windows") + { + Challenges = ImmutableDictionary.Empty.Add("MyJwt", "Bearer") + }); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Equal("Negotiate", challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_SkipUnregisteredSchemes_When_ListedSchemeIsNotRegistered() + { + // arrange + var options = new FusionAuthorizationOptions + { + Schemes = ImmutableArray.Create("Windows", "MyJwt"), + SchemeChallenges = ImmutableDictionary.Empty.Add("Windows", "Negotiate") + }; + var schemeResolver = new AuthenticationSchemeResolver( + options, + new TestAuthenticationSchemeLookup("MyJwt") + { + Challenges = ImmutableDictionary.Empty.Add("MyJwt", "Bearer") + }); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Equal("Bearer", challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_ReturnNull_When_NoSchemeIsRegistered() + { + // arrange + var schemeResolver = new AuthenticationSchemeResolver( + new FusionAuthorizationOptions { Schemes = ImmutableArray.Create("Bearer") }, + new TestAuthenticationSchemeLookup()); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Null(challenge); + } + + [Fact] + public async Task GetChallengeAsync_Should_ReturnNull_When_HostHasNoLookup() + { + // arrange + var schemeResolver = new AuthenticationSchemeResolver(new FusionAuthorizationOptions(), lookup: null); + + // act + var challenge = await schemeResolver.GetChallengeAsync(CancellationToken.None); + + // assert + Assert.Null(challenge); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthorizationOptionsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthorizationOptionsTests.cs new file mode 100644 index 00000000000..e7a693935a9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthorizationOptionsTests.cs @@ -0,0 +1,229 @@ +using System.Collections.Immutable; +using System.Security.Claims; +using HotChocolate.Execution; +using HotChocolate.Fusion.Configuration; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using static HotChocolate.Fusion.Authorization.PolicyTestHelper; + +namespace HotChocolate.Fusion.Authorization; + +public class AuthorizationOptionsTests : FusionTestBase +{ + [Fact] + public async Task ModifyAuthorizationOptions_Should_ExposeDefaults_When_NoModifierIsRegistered() + { + // arrange + var executor = await CreateExecutorAsync(builder => builder); + + // act + var options = executor.Schema.Services.GetRequiredService(); + + // assert + Describe(options).MatchInlineSnapshot( + """ + DenyHandling: Null + RejectRequestOn: Off + Schemes: unset + SchemeChallenges: none + ScopeClaimName: scope + ScopeClaimFormat: SpaceSeparated + EnableAttribution: False + DisableAuthorizationValidation: False + """); + } + + [Fact] + public async Task ModifyAuthorizationOptions_Should_ApplyEveryModifier_When_ExecutorIsCreated() + { + // arrange + var executor = await CreateExecutorAsync( + builder => builder + .ModifyAuthorizationOptions( + o => + { + o.DenyHandling = DenyHandling.Error; + o.RejectRequestOn = RejectRequestOn.OnUnauthenticated; + o.Schemes = ImmutableArray.Create("Bearer", "Cookie"); + o.SchemeChallenges = ImmutableDictionary.Empty.Add("Cookie", "Cookie"); + }) + .ModifyAuthorizationOptions( + o => + { + o.RejectRequestOn = RejectRequestOn.OnUnauthorized; + o.ScopeClaimName = "scp"; + o.ScopeClaimFormat = ScopeClaimFormat.Array; + o.EnableAttribution = true; + o.DisableAuthorizationValidation = true; + })); + + // act + var options = executor.Schema.Services.GetRequiredService(); + + // assert + Describe(options).MatchInlineSnapshot( + """ + DenyHandling: Error + RejectRequestOn: OnUnauthorized + Schemes: Bearer,Cookie + SchemeChallenges: Cookie=Cookie + ScopeClaimName: scp + ScopeClaimFormat: Array + EnableAttribution: True + DisableAuthorizationValidation: True + """); + } + + [Fact] + public async Task Options_Should_BeReadOnly_When_ExecutorIsCreated() + { + // arrange + var executor = await CreateExecutorAsync(builder => builder); + var options = executor.Schema.Services.GetRequiredService(); + + // act + Action act = () => options.DenyHandling = DenyHandling.Error; + + // assert + var exception = Assert.Throws(act); + Assert.Equal("The authorization options are read-only.", exception.Message); + } + + [Fact] + public void Schemes_Should_BeUnset_When_DefaultArrayIsAssigned() + { + // arrange + var options = new FusionAuthorizationOptions { Schemes = ImmutableArray.Create("Bearer") }; + + // act + options.Schemes = default(ImmutableArray); + + // assert + Assert.Null(options.Schemes); + } + + [Fact] + public void SchemeChallenges_Should_Throw_When_ValueIsNull() + { + // arrange + var options = new FusionAuthorizationOptions(); + + // act + var act = () => options.SchemeChallenges = null!; + + // assert + var exception = Assert.Throws(act); + Assert.Equal("value", exception.ParamName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void SchemeChallenges_Should_Throw_When_ChallengeIsEmpty(string? challenge) + { + // arrange + var options = new FusionAuthorizationOptions(); + var challenges = ImmutableDictionary.Empty.Add("MyJwt", challenge!); + + // act + var act = () => options.SchemeChallenges = challenges; + + // assert + var exception = Assert.Throws(act); + Assert.Equal( + "The WWW-Authenticate challenge of the authentication scheme 'MyJwt' must not be null, " + + "empty or whitespace. (Parameter 'value')", + exception.Message); + } + + [Fact] + public void ScopeClaimName_Should_Throw_When_ValueIsEmpty() + { + // arrange + var options = new FusionAuthorizationOptions(); + + // act + var act = () => options.ScopeClaimName = string.Empty; + + // assert + var exception = Assert.Throws(act); + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public async Task PolicyResolver_Should_ReadConfiguredScopeClaim_When_ScopeOptionsAreModified() + { + // arrange + var executor = await CreateExecutorAsync( + builder => builder.ModifyAuthorizationOptions( + o => + { + o.ScopeClaimName = "scp"; + o.ScopeClaimFormat = ScopeClaimFormat.Array; + })); + var resolver = executor.Schema.Services.GetRequiredService(); + var policy = resolver.Resolve(string.Empty, DirectiveNames.RequiresScopes.Name)!; + var selections = CreateSelections(); + var context = CreateContext( + Authenticated(new Claim("scope", "read"), new Claim("scp", "read"), new Claim("scp", "write")), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "write"]]), + CreateEntry(selections[2], policy, DirectiveNames.RequiresScopes.Name, null, [["read write"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Allowed, PolicyOutcome.Denied], Outcomes(context)); + } + + [Fact] + public async Task PolicyResolver_Should_ReadScopeClaim_When_ScopeOptionsAreNotModified() + { + // arrange + var executor = await CreateExecutorAsync(builder => builder); + var resolver = executor.Schema.Services.GetRequiredService(); + var policy = resolver.Resolve(string.Empty, DirectiveNames.RequiresScopes.Name)!; + var selections = CreateSelections(); + var context = CreateContext( + Authenticated(new Claim("scope", "read write")), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "write"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Allowed], Outcomes(context)); + } + + private static async Task CreateExecutorAsync( + Func configure) + { + var services = new ServiceCollection(); + var builder = services + .AddGraphQLGateway() + .AddInMemoryConfiguration(ComposeSchemaDocument("type Query { field: String! }")); + configure(builder); + + return await services + .BuildServiceProvider() + .GetRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); + } + + private static string Describe(FusionAuthorizationOptions options) + => string.Join( + '\n', + $"DenyHandling: {options.DenyHandling}", + $"RejectRequestOn: {options.RejectRequestOn}", + $"Schemes: {(options.Schemes is { } schemes ? string.Join(',', schemes) : "unset")}", + $"SchemeChallenges: {DescribeChallenges(options.SchemeChallenges)}", + $"ScopeClaimName: {options.ScopeClaimName}", + $"ScopeClaimFormat: {options.ScopeClaimFormat}", + $"EnableAttribution: {options.EnableAttribution}", + $"DisableAuthorizationValidation: {options.DisableAuthorizationValidation}"); + + private static string DescribeChallenges(ImmutableDictionary challenges) + => challenges.IsEmpty + ? "none" + : string.Join(',', challenges.Select(challenge => $"{challenge.Key}={challenge.Value}")); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthorizationStartupValidationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthorizationStartupValidationTests.cs new file mode 100644 index 00000000000..6a5abb91baf --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/AuthorizationStartupValidationTests.cs @@ -0,0 +1,377 @@ +using System.Collections.Immutable; +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Authorization; + +public class AuthorizationStartupValidationTests : FusionTestBase +{ + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + private const string NoSchemeMessage = + "The schema uses authorization directives but no authentication scheme is registered. " + + "Register an authentication scheme, or disable the authorization validation."; + + [Theory] + [InlineData("secret: String @authenticated")] + [InlineData("""secret: String @requiresScopes(scopes: [["read"]])""")] + [InlineData("""secret: String @policy(policies: [["admin"]])""")] + public async Task Startup_Should_Fail_When_SchemaUsesAuthorizationAndNoSchemeIsRegistered(string field) + { + // arrange + var provider = CreateProvider( + field, + lookup: new TestAuthenticationSchemeLookup(), + policies: policies => policies.Allow("admin")); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal(NoSchemeMessage, exception.Message); + } + + [Fact] + public async Task Startup_Should_Succeed_When_SchemaUsesAuthorizationAndSchemeIsRegistered() + { + // arrange + var provider = CreateProvider( + "secret: String @authenticated", + lookup: new TestAuthenticationSchemeLookup("Bearer")); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Fail_When_ListedSchemeIsNotRegistered() + { + // arrange + var provider = CreateProvider( + "secret: String @authenticated", + lookup: new TestAuthenticationSchemeLookup("Bearer"), + configure: o => o.Schemes = ImmutableArray.Create("Bearer", "Cookie")); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The authentication scheme 'Cookie' of the authorization options is not registered. " + + "Register it, remove it from the schemes, or disable the authorization validation.", + exception.Message); + } + + [Fact] + public async Task Startup_Should_Fail_When_ListedSchemeIsNotRegisteredAndSchemaUsesNoAuthorization() + { + // arrange + var provider = CreateProvider( + "field: String", + lookup: new TestAuthenticationSchemeLookup(), + configure: o => o.Schemes = ImmutableArray.Create("Bearer")); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The authentication scheme 'Bearer' of the authorization options is not registered. " + + "Register it, remove it from the schemes, or disable the authorization validation.", + exception.Message); + } + + [Theory] + [InlineData("secret: String @authenticated")] + [InlineData("field: String")] + public async Task Startup_Should_Fail_When_SchemesAreEmpty(string field) + { + // arrange + var provider = CreateProvider( + field, + lookup: new TestAuthenticationSchemeLookup("Bearer"), + configure: o => o.Schemes = ImmutableArray.Empty); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The Schemes list is empty; omit it to use every registered scheme or list at least one.", + exception.Message); + } + + [Theory] + [InlineData("secret: String @authenticated")] + [InlineData("field: String")] + public async Task Startup_Should_Fail_When_SchemesAreListedAndHostHasNoAuthenticationSchemeLookup(string field) + { + // arrange + var provider = CreateProvider( + field, + lookup: null, + configure: o => o.Schemes = ImmutableArray.Create("Bearer")); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The Schemes option lists authentication schemes but the host exposes no authentication schemes. " + + "Register the authentication scheme lookup through HotChocolate.Fusion.AspNetCore, " + + "or do not set Schemes.", + exception.Message); + } + + [Fact] + public async Task Startup_Should_Succeed_When_SchemaUsesNoAuthorizationAndListedSchemesAreRegistered() + { + // arrange + var provider = CreateProvider( + "field: String", + lookup: new TestAuthenticationSchemeLookup("Bearer", "Cookie"), + configure: o => o.Schemes = ImmutableArray.Create("Cookie")); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Succeed_When_ListedSchemesAreRegistered() + { + // arrange + var provider = CreateProvider( + "secret: String @authenticated", + lookup: new TestAuthenticationSchemeLookup("Bearer", "Cookie"), + configure: o => o.Schemes = ImmutableArray.Create("Cookie")); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Succeed_When_SchemaUsesNoAuthorizationAndNoSchemeIsRegistered() + { + // arrange + var provider = CreateProvider( + "field: String", + lookup: new TestAuthenticationSchemeLookup()); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Fail_When_SchemaUsesAuthorizationAndHostHasNoAuthenticationSchemeLookup() + { + // arrange + var provider = CreateProvider("secret: String @authenticated", lookup: null); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "The schema uses authorization directives but the host exposes no authentication schemes. " + + "Add the gateway through HotChocolate.Fusion.AspNetCore, or set DisableAuthorizationValidation.", + exception.Message); + } + + [Fact] + public async Task Startup_Should_Fail_When_PolicyNameIsNotResolvable() + { + // arrange + var provider = CreateProvider( + """secret: String @policy(policies: [["admin", "auditor"]])""", + lookup: new TestAuthenticationSchemeLookup("Bearer"), + policies: policies => policies.Allow("admin")); + + // act + var act = async () => await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + var exception = await Assert.ThrowsAsync(act); + Assert.Equal( + "No policy provider knows the policy 'auditor' of the directive '@policy'.", + exception.Message); + } + + [Fact] + public async Task Startup_Should_Succeed_When_EveryPolicyNameIsResolvable() + { + // arrange + var provider = CreateProvider( + """secret: String @policy(policies: [["admin", "auditor"]])""", + lookup: new TestAuthenticationSchemeLookup("Bearer"), + policies: policies => policies.Allow("admin").Deny("auditor")); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Succeed_When_ValidationIsDisabled() + { + // arrange + var provider = CreateProvider( + """ + secret: String @authenticated + guarded: String @policy(policies: [["admin"]]) + """, + lookup: new TestAuthenticationSchemeLookup(), + configure: o => + { + o.Schemes = ImmutableArray.Create("Bearer"); + o.DisableAuthorizationValidation = true; + }); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Succeed_When_ValidationIsDisabledAndSchemesAreEmpty() + { + // arrange + var provider = CreateProvider( + "field: String", + lookup: new TestAuthenticationSchemeLookup("Bearer"), + configure: o => + { + o.Schemes = ImmutableArray.Empty; + o.DisableAuthorizationValidation = true; + }); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Succeed_When_ValidationIsDisabledAndListedSchemeIsNotRegistered() + { + // arrange + var provider = CreateProvider( + "field: String", + lookup: new TestAuthenticationSchemeLookup("Bearer"), + configure: o => + { + o.Schemes = ImmutableArray.Create("Bearer", "Cookie"); + o.DisableAuthorizationValidation = true; + }); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + [Fact] + public async Task Startup_Should_Succeed_When_ValidationIsDisabledAndSchemesAreListedWithoutLookup() + { + // arrange + var provider = CreateProvider( + "field: String", + lookup: null, + configure: o => + { + o.Schemes = ImmutableArray.Create("Bearer"); + o.DisableAuthorizationValidation = true; + }); + + // act + var executor = await provider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // assert + Assert.Equal(ISchemaDefinition.DefaultName, executor.Schema.Name); + } + + private static IServiceProvider CreateProvider( + string fields, + IAuthenticationSchemeLookup? lookup, + Action? configure = null, + Action? policies = null) + { + var services = new ServiceCollection(); + var builder = services + .AddGraphQLGateway() + .AddInMemoryConfiguration( + ComposeSchemaDocument( + $$""" + type Query { + {{fields}} + } + + {{Directives}} + """)); + + if (lookup is not null) + { + builder.ConfigureSchemaServices((_, sc) => sc.AddSingleton(lookup)); + } + + if (configure is not null) + { + builder.ModifyAuthorizationOptions(configure); + } + + if (policies is not null) + { + builder.AddInMemoryPolicies(policies); + } + + return services.BuildServiceProvider(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/BuiltInPolicyTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/BuiltInPolicyTests.cs new file mode 100644 index 00000000000..dbf5bc5044e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/BuiltInPolicyTests.cs @@ -0,0 +1,182 @@ +using System.Security.Claims; +using HotChocolate.Types; +using static HotChocolate.Fusion.Authorization.PolicyTestHelper; + +namespace HotChocolate.Fusion.Authorization; + +public class BuiltInPolicyTests +{ + [Fact] + public async Task AuthenticatedPolicy_Should_AllowAllEntries_When_PrincipalIsAuthenticated() + { + // arrange + var selections = CreateSelections(); + var policy = AuthenticatedPolicy.Instance; + var context = CreateContext( + Authenticated(), + CreateEntry(selections[1], policy, DirectiveNames.Authenticated.Name, null), + CreateEntry(selections[2], policy, DirectiveNames.Authenticated.Name, null)); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Allowed, PolicyOutcome.Allowed], Outcomes(context)); + } + + [Fact] + public async Task AuthenticatedPolicy_Should_DenyAllEntries_When_PrincipalIsAnonymous() + { + // arrange + var selections = CreateSelections(); + var policy = AuthenticatedPolicy.Instance; + var context = CreateContext( + Anonymous(), + CreateEntry(selections[1], policy, DirectiveNames.Authenticated.Name, null), + CreateEntry(selections[2], policy, DirectiveNames.Authenticated.Name, null)); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Denied, PolicyOutcome.Denied], Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_DenyEvenWithScopeClaim_When_PrincipalIsNotAuthenticated() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy(); + var anonymousWithScope = new ClaimsPrincipal( + new ClaimsIdentity([new Claim("scope", "read")])); + var context = CreateContext( + anonymousWithScope, + CreateEntry( + selections[1], + policy, + DirectiveNames.RequiresScopes.Name, + null, + [["read"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Denied], Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_CombineGroupsAsOrOfAnd_When_ScopesAreEvaluated() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy(); + var context = CreateContext( + Authenticated(new Claim("scope", "read write"), new Claim("scope", "admin")), + CreateEntry(selections[0], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "write"]]), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "delete"], ["admin"]]), + CreateEntry(selections[2], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "delete"]]), + CreateEntry(selections[2], policy, DirectiveNames.RequiresScopes.Name, null, [])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal( + [PolicyOutcome.Allowed, PolicyOutcome.Allowed, PolicyOutcome.Denied, PolicyOutcome.Denied], + Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_ReadConfiguredClaimType_When_ClaimTypeIsGiven() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy("scp", ScopeClaimFormat.SpaceSeparated); + var context = CreateContext( + Authenticated(new Claim("scope", "read"), new Claim("scp", "write")), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["write"]]), + CreateEntry(selections[2], policy, DirectiveNames.RequiresScopes.Name, null, [["read"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Allowed, PolicyOutcome.Denied], Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_ReadOneScopePerClaim_When_FormatIsArray() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy("scp", ScopeClaimFormat.Array); + var context = CreateContext( + Authenticated(new Claim("scp", "read"), new Claim("scp", "write")), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "write"]]), + CreateEntry(selections[2], policy, DirectiveNames.RequiresScopes.Name, null, [["read"], ["admin"]]), + CreateEntry(selections[0], policy, DirectiveNames.RequiresScopes.Name, null, [["admin"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal( + [PolicyOutcome.Allowed, PolicyOutcome.Allowed, PolicyOutcome.Denied], + Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_NotSplitClaimValues_When_FormatIsArray() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy("scp", ScopeClaimFormat.Array); + var context = CreateContext( + Authenticated(new Claim("scp", "read write")), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["read"]]), + CreateEntry(selections[2], policy, DirectiveNames.RequiresScopes.Name, null, [["read write"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Denied, PolicyOutcome.Allowed], Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_SplitClaimValues_When_FormatIsSpaceSeparated() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy("scp", ScopeClaimFormat.SpaceSeparated); + var context = CreateContext( + Authenticated(new Claim("scp", "read write")), + CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null, [["read", "write"]])); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.Equal([PolicyOutcome.Allowed], Outcomes(context)); + } + + [Fact] + public async Task RequiresScopesPolicy_Should_DenyWithEmptyScopes_When_DescriptorScopesAreDefault() + { + // arrange + var selections = CreateSelections(); + var policy = new RequiresScopesPolicy(); + var entry = CreateEntry(selections[1], policy, DirectiveNames.RequiresScopes.Name, null); + var context = CreateContext(Authenticated(new Claim("scope", "read")), entry); + + // act + await policy.EvaluateAsync(context, CancellationToken.None); + + // assert + Assert.False(entry.Descriptor.Scopes.IsDefault); + Assert.Empty(entry.Descriptor.Scopes); + Assert.Equal([PolicyOutcome.Denied], Outcomes(context)); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/InMemoryPolicyTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/InMemoryPolicyTests.cs new file mode 100644 index 00000000000..2c7965c1545 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/InMemoryPolicyTests.cs @@ -0,0 +1,175 @@ +using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization.InMemory; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using static HotChocolate.Fusion.Authorization.PolicyTestHelper; + +namespace HotChocolate.Fusion.Authorization; + +public class InMemoryPolicyTests : FusionTestBase +{ + [Fact] + public async Task AddInMemoryPolicies_Should_AnswerConfiguredVerdicts_When_PoliciesAreResolved() + { + // arrange + var (resolver, _) = await CreateResolverAsync( + policies => policies + .Allow("allowed") + .Deny("denied", "no") + .Evaluate("odd", (_, entry) => entry.Selection.Id % 2 == 1)); + var selections = CreateSelections(); + + // act + var allowed = await EvaluateAsync(resolver, "allowed", selections[1], selections[2]); + var denied = await EvaluateAsync(resolver, "denied", selections[1]); + var odd = await EvaluateAsync(resolver, "odd", selections[0], selections[1], selections[2]); + + // assert + Assert.Equal([PolicyOutcome.Allowed, PolicyOutcome.Allowed], allowed.Select(v => v.Outcome)); + Assert.Equal([new PolicyVerdict(PolicyOutcome.Denied, "no", null)], denied); + Assert.Equal( + selections.Select(s => s.Id % 2 == 1 ? PolicyOutcome.Allowed : PolicyOutcome.Denied), + odd.Select(v => v.Outcome)); + } + + [Fact] + public async Task AddInMemoryPolicies_Should_LeaveEntriesUnanswered_When_PolicyIsConfiguredUnanswered() + { + // arrange + var (resolver, _) = await CreateResolverAsync(policies => policies.Unanswered("silent")); + var selections = CreateSelections(); + + // act + var verdicts = await EvaluateAsync(resolver, "silent", selections[1], selections[2]); + + // assert + Assert.Equal( + [PolicyOutcome.Unanswered, PolicyOutcome.Unanswered], + verdicts.Select(v => v.Outcome)); + } + + [Fact] + public async Task Resolve_Should_ReturnNull_When_PolicyNameIsNotConfigured() + { + // arrange + var (resolver, _) = await CreateResolverAsync(policies => policies.Allow("known")); + + // act + var policy = resolver.Resolve("unknown", DirectiveNames.Policy.Name); + + // assert + Assert.Null(policy); + } + + [Fact] + public async Task Resolve_Should_FallThroughToSecondInMemoryProvider_When_FirstDoesNotKnowTheName() + { + // arrange + var services = new ServiceCollection(); + services + .AddGraphQLGateway() + .AddInMemoryPolicies(policies => policies.Allow("a")) + .AddInMemoryPolicies(policies => policies.Deny("b")) + .AddInMemoryConfiguration(ComposeSchemaDocument("type Query { field: String! }")); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + var executor = await serviceProvider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + var resolver = executor.Schema.Services.GetRequiredService(); + var selections = CreateSelections(); + + // act + var verdicts = await EvaluateAsync(resolver, "b", selections[1]); + + // assert + Assert.Equal([PolicyOutcome.Denied], verdicts.Select(v => v.Outcome)); + } + + [Fact] + public async Task Recorder_Should_RecordEntriesInEvaluationOrder_When_PoliciesAreEvaluated() + { + // arrange + var (resolver, recorder) = await CreateResolverAsync( + policies => policies.Allow("a").Deny("b")); + var selections = CreateSelections(); + + // act + await EvaluateAsync(resolver, "b", selections[2]); + await EvaluateAsync(resolver, "a", selections[0], selections[1]); + + // assert + Assert.Equal( + [("b", selections[2].Id), ("a", selections[0].Id), ("a", selections[1].Id)], + recorder.Records.Select(r => (r.PolicyName, r.Entry.Selection.Id))); + } + + [Fact] + public async Task Recorder_Should_BeEmpty_When_ClearIsCalled() + { + // arrange + var (resolver, recorder) = await CreateResolverAsync(policies => policies.Allow("a")); + var selections = CreateSelections(); + await EvaluateAsync(resolver, "a", selections[1]); + + // act + recorder.Clear(); + + // assert + Assert.Empty(recorder.Records); + } + + [Fact] + public async Task Resolve_Should_AnswerOnlyBuiltInPolicies_When_InMemoryPoliciesAreNotRegistered() + { + // arrange + var services = new ServiceCollection(); + services + .AddGraphQLGateway() + .AddInMemoryConfiguration(ComposeSchemaDocument("type Query { field: String! }")); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + var executor = await serviceProvider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + var resolver = executor.Schema.Services.GetRequiredService(); + + // act + var authenticated = resolver.Resolve("p", DirectiveNames.Authenticated.Name); + var policy = resolver.Resolve("p", DirectiveNames.Policy.Name); + + // assert + Assert.Same(AuthenticatedPolicy.Instance, authenticated); + Assert.Null(policy); + } + + private async Task<(IPolicyResolver Resolver, InMemoryPolicyRecorder Recorder)> CreateResolverAsync( + Action configure) + { + var services = new ServiceCollection(); + services + .AddGraphQLGateway() + .AddInMemoryPolicies(configure) + .AddInMemoryConfiguration(ComposeSchemaDocument("type Query { field: String! }")); + + IServiceProvider serviceProvider = services.BuildServiceProvider(); + var executor = await serviceProvider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + + return ( + executor.Schema.Services.GetRequiredService(), + serviceProvider.GetRequiredService()); + } + + private static async Task EvaluateAsync( + IPolicyResolver resolver, + string policyName, + params ISelection[] selections) + { + var policy = resolver.Resolve(policyName, DirectiveNames.Policy.Name); + Assert.NotNull(policy); + + var context = CreateContext( + Authenticated(), + selections.Select(s => CreateEntry(s, policy, policyName: policyName)).ToArray()); + + await policy.EvaluateAsync(context, CancellationToken.None); + return context.Verdicts.ToArray(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyEvaluationContextTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyEvaluationContextTests.cs new file mode 100644 index 00000000000..2d2faa1b768 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyEvaluationContextTests.cs @@ -0,0 +1,161 @@ +using System.Collections.Immutable; +using HotChocolate.Features; +using Microsoft.Extensions.DependencyInjection; +using static HotChocolate.Fusion.Authorization.PolicyTestHelper; + +namespace HotChocolate.Fusion.Authorization; + +public class PolicyEvaluationContextTests +{ + [Fact] + public void Verdicts_Should_BeUnanswered_When_NoEntryWasTouched() + { + // arrange + var selections = CreateSelections(); + var policy = AuthenticatedPolicy.Instance; + var context = CreateContext( + Anonymous(), + CreateEntry(selections[1], policy), + CreateEntry(selections[2], policy)); + + // act + var outcomes = Outcomes(context); + + // assert + Assert.Equal([PolicyOutcome.Unanswered, PolicyOutcome.Unanswered], outcomes); + } + + [Fact] + public void Allow_Should_OnlyAnswerTheGivenEntry_When_CalledForOneEntry() + { + // arrange + var selections = CreateSelections(); + var policy = AuthenticatedPolicy.Instance; + var context = CreateContext( + Anonymous(), + CreateEntry(selections[1], policy), + CreateEntry(selections[2], policy)); + + // act + context.Allow(context.Entries[1]); + + // assert + Assert.Equal([PolicyOutcome.Unanswered, PolicyOutcome.Allowed], Outcomes(context)); + } + + [Fact] + public void Allow_Should_NotOverrideDenial_When_EntryWasDeniedBefore() + { + // arrange + var selections = CreateSelections(); + var context = CreateContext( + Anonymous(), + CreateEntry(selections[1], AuthenticatedPolicy.Instance)); + context.Deny(context.Entries[0], "no"); + + // act + context.Allow(context.Entries[0]); + + // assert + Assert.Equal( + new PolicyVerdict(PolicyOutcome.Denied, "no", null), + context.Verdicts[0]); + } + + [Fact] + public void Deny_Should_ReplaceAllowance_When_EntryWasAllowedBefore() + { + // arrange + var selections = CreateSelections(); + var context = CreateContext( + Anonymous(), + CreateEntry(selections[1], AuthenticatedPolicy.Instance)); + context.Allow(context.Entries[0]); + + // act + context.Deny(context.Entries[0], "later"); + + // assert + Assert.Equal( + new PolicyVerdict(PolicyOutcome.Denied, "later", null), + context.Verdicts[0]); + } + + [Fact] + public void Deny_Should_RecordReasonAndAuditData_When_Provided() + { + // arrange + var selections = CreateSelections(); + var context = CreateContext( + Anonymous(), + CreateEntry(selections[1], AuthenticatedPolicy.Instance)); + var auditData = ImmutableDictionary.Empty.Add("rule", "r1"); + + // act + context.Deny(context.Entries[0], "no", auditData); + + // assert + var verdict = context.Verdicts[0]; + Assert.Equal(PolicyOutcome.Denied, verdict.Outcome); + Assert.Equal("no", verdict.Reason); + Assert.Same(auditData, verdict.AuditData); + } + + [Fact] + public void Allow_Should_Throw_When_EntryIsNotPartOfTheContext() + { + // arrange + var selections = CreateSelections(); + var policy = AuthenticatedPolicy.Instance; + var context = CreateContext(Anonymous(), CreateEntry(selections[1], policy)); + var foreign = CreateEntry(selections[1], policy); + + // act + void Act() => context.Allow(foreign); + + // assert + var exception = Assert.Throws(Act); + Assert.Equal("The policy entry is not part of this evaluation context.", exception.Message); + } + + [Fact] + public void Plan_Should_ExposeTheOperationPlan_When_ContextIsCreated() + { + // arrange + var plan = CreatePlan(); + + // act + var context = new PolicyEvaluationContext( + Anonymous(), + new FeatureCollection(), + new ServiceCollection().BuildServiceProvider(), + plan, + 0, + []); + + // assert + Assert.Same(plan, context.Plan); + Assert.Equal("GetProduct", context.Plan.Operation.Name); + } + + [Fact] + public void Constructor_Should_Throw_When_PlanIsNull() + { + // arrange + var services = new ServiceCollection().BuildServiceProvider(); + + // act + void Act() + => _ = new PolicyEvaluationContext( + Anonymous(), + new FeatureCollection(), + services, + null!, + 0, + []); + + // assert + var exception = Assert.Throws(Act); + Assert.Equal("plan", exception.ParamName); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyResolverTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyResolverTests.cs new file mode 100644 index 00000000000..70b2fa33aea --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyResolverTests.cs @@ -0,0 +1,121 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Authorization; + +public class PolicyResolverTests +{ + [Fact] + public void Resolve_Should_ReturnPolicyOfFirstProvider_When_SeveralProvidersKnowTheName() + { + // arrange + var first = new StubPolicy(); + var second = new StubPolicy(); + var resolver = new PolicyResolver( + new BuiltInPolicyProvider(), + [new StubProvider(first), new StubProvider(second)]); + + // act + var policy = resolver.Resolve("p", DirectiveNames.Policy.Name); + + // assert + Assert.Same(first, policy); + } + + [Fact] + public void Resolve_Should_FallThroughToNextProvider_When_FirstProviderReturnsNull() + { + // arrange + var second = new StubPolicy(); + var resolver = new PolicyResolver( + new BuiltInPolicyProvider(), + [new StubProvider(null), new StubProvider(second)]); + + // act + var policy = resolver.Resolve("p", DirectiveNames.Policy.Name); + + // assert + Assert.Same(second, policy); + } + + [Fact] + public void Resolve_Should_ReturnNull_When_NoProviderKnowsThePolicy() + { + // arrange + var resolver = new PolicyResolver( + new BuiltInPolicyProvider(), + [new StubProvider(null)]); + + // act + var policy = resolver.Resolve("p", DirectiveNames.Policy.Name); + + // assert + Assert.Null(policy); + } + + [Fact] + public void Resolve_Should_ReturnBuiltInPolicy_When_UserProviderAnswersBuiltInDirective() + { + // arrange + var resolver = new PolicyResolver( + new BuiltInPolicyProvider(), + [new StubProvider(new StubPolicy())]); + + // act + var authenticated = resolver.Resolve(string.Empty, DirectiveNames.Authenticated.Name); + var scopes = resolver.Resolve(string.Empty, DirectiveNames.RequiresScopes.Name); + + // assert + Assert.Same(AuthenticatedPolicy.Instance, authenticated); + Assert.IsType(scopes); + } + + [Fact] + public void GetPolicy_Should_ReturnNull_When_DirectiveIsPolicy() + { + // arrange + var provider = new BuiltInPolicyProvider(); + + // act + var policy = provider.GetPolicy("authenticated", DirectiveNames.Policy.Name); + + // assert + Assert.Null(policy); + } + + [Fact] + public void Get_Should_OrderAuthenticatedBeforeScopesBeforePolicies_When_Compared() + { + // arrange + string[] names = + [ + DirectiveNames.Policy.Name, + DirectiveNames.RequiresScopes.Name, + DirectiveNames.Authenticated.Name + ]; + + // act + var ordered = names.OrderBy(PolicyEvaluationOrder.Get).ToArray(); + + // assert + Assert.Equal( + [ + DirectiveNames.Authenticated.Name, + DirectiveNames.RequiresScopes.Name, + DirectiveNames.Policy.Name + ], + ordered); + } + + private sealed class StubPolicy : IPolicy + { + public ValueTask EvaluateAsync( + PolicyEvaluationContext context, + CancellationToken cancellationToken) + => ValueTask.CompletedTask; + } + + private sealed class StubProvider(IPolicy? policy) : IPolicyProvider + { + public IPolicy? GetPolicy(string policyName, string directiveName) => policy; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyTestHelper.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyTestHelper.cs new file mode 100644 index 00000000000..89c0965b041 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/PolicyTestHelper.cs @@ -0,0 +1,85 @@ +using System.Collections.Immutable; +using System.Security.Claims; +using HotChocolate.Execution; +using HotChocolate.Features; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Language; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; + +namespace HotChocolate.Fusion.Authorization; + +internal static class PolicyTestHelper +{ + public static ISelection[] CreateSelections() + { + var schema = OperationCompilerTests.CreateSchema(); + + var document = Utf8GraphQLParser.Parse("query { product { id name } }"); + var definition = document.Definitions.OfType().First(); + var pool = new DefaultObjectPool>>( + new FieldMapPooledObjectPolicy()); + var operation = new OperationCompiler(schema, pool).Compile("1", "1", "1", definition); + + var product = operation.RootSelectionSet.Selections[0]; + var productSet = operation.GetSelectionSet(product, product.Type.NamedType()); + + return [product, .. productSet.Selections.ToArray()]; + } + + public static PolicyEvaluationEntry CreateEntry( + ISelection selection, + IPolicy policy, + string directiveName = DirectiveNames.Policy.Name, + string? policyName = "p", + ImmutableArray> scopes = default) + => new( + new PolicyDescriptor(directiveName, policyName, scopes, selection, policy), + new Dictionary()); + + public static PolicyEvaluationContext CreateContext( + ClaimsPrincipal user, + params PolicyEvaluationEntry[] entries) + => new( + user, + new FeatureCollection(), + new ServiceCollection().BuildServiceProvider(), + CreatePlan(), + 0, + entries); + + public static OperationPlan CreatePlan() + => PlanFactory.Create(); + + public static ClaimsPrincipal Authenticated(params Claim[] claims) + => new(new ClaimsIdentity(claims, "test")); + + public static ClaimsPrincipal Anonymous() + => new(new ClaimsIdentity()); + + public static PolicyOutcome[] Outcomes(PolicyEvaluationContext context) + => context.Verdicts.ToArray().Select(v => v.Outcome).ToArray(); + + private sealed class PlanFactory : FusionTestBase + { + public static OperationPlan Create() + { + var schema = ComposeSchema( + """ + # name: a + type Query { + product: Product + } + + type Product { + id: ID! + name: String! + } + """); + + return PlanOperation(schema, "query GetProduct { product { id name } }"); + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/TestAuthenticationSchemeLookup.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/TestAuthenticationSchemeLookup.cs new file mode 100644 index 00000000000..8e4669520b3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/TestAuthenticationSchemeLookup.cs @@ -0,0 +1,21 @@ +using System.Collections.Immutable; + +namespace HotChocolate.Fusion.Authorization; + +internal sealed class TestAuthenticationSchemeLookup(params string[] schemeNames) : IAuthenticationSchemeLookup +{ + private readonly ImmutableArray _schemeNames = [.. schemeNames]; + + public ImmutableDictionary Challenges { get; init; } = +#if NET10_0_OR_GREATER + []; +#else + ImmutableDictionary.Empty; +#endif + + public ValueTask> GetSchemeNamesAsync(CancellationToken cancellationToken) + => new(_schemeNames); + + public ValueTask GetChallengeAsync(string schemeName, CancellationToken cancellationToken) + => new(Challenges.GetValueOrDefault(schemeName)); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/TestPolicyResolver.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/TestPolicyResolver.cs new file mode 100644 index 00000000000..935e7f5b597 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Authorization/TestPolicyResolver.cs @@ -0,0 +1,7 @@ +namespace HotChocolate.Fusion.Authorization; + +internal static class TestPolicyResolver +{ + public static IPolicyResolver Create() + => new PolicyResolver(new BuiltInPolicyProvider(), []); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionAuthorizationDirectiveTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionAuthorizationDirectiveTests.cs new file mode 100644 index 00000000000..b15112d1d48 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Types/FusionAuthorizationDirectiveTests.cs @@ -0,0 +1,342 @@ +using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Directives; +using HotChocolate.Fusion.Types.Metadata; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Execution.Types; + +public sealed class FusionAuthorizationDirectiveTests : FusionTestBase +{ + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + private const string SchemaA = + $$""" + type Query @authenticated { + secured: Int @shareable @requiresScopes(scopes: [["read"], ["admin"]]) + open: Int + iface: FooInterface + kind: FooEnum + custom: FooScalar + } + + interface FooInterface @policy(policies: [["Admin"]]) { + id: Int + } + + enum FooEnum @authenticated { + ONE + } + + scalar FooScalar @requiresScopes(scopes: [["scalar"]]) + + {{Directives}} + """; + + private const string SchemaB = + $$""" + type Query { + secured: Int @shareable @policy(policies: [["Editor"], ["Admin", "Auditor"]]) + } + + {{Directives}} + """; + + [Fact] + public void Create_Should_ParseAuthorization_When_MembersCarryFusionAuthorization() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var query = schema.Types.GetType("Query"); + var members = new (string Coordinate, AuthorizationDirective? Authorization)[] + { + ("Query", query.Authorization), + ("Query.secured", query.Fields["secured"].Authorization), + ("Query.open", query.Fields["open"].Authorization), + ("FooInterface", schema.Types.GetType("FooInterface").Authorization), + ("FooEnum", schema.Types.GetType("FooEnum").Authorization), + ("FooScalar", schema.Types.GetType("FooScalar").Authorization) + }; + + // assert + members + .Select(static m => new + { + m.Coordinate, + Authorization = m.Authorization is null + ? null + : new + { + m.Authorization.Authenticated, + m.Authorization.Scopes, + m.Authorization.Policies + } + }) + .ToArray() + .MatchInlineSnapshot( + """ + [ + { + "Coordinate": "Query", + "Authorization": { + "Authenticated": true, + "Scopes": [], + "Policies": [] + } + }, + { + "Coordinate": "Query.secured", + "Authorization": { + "Authenticated": true, + "Scopes": [ + [ + "admin" + ], + [ + "read" + ] + ], + "Policies": [ + [ + "Editor" + ], + [ + "Admin", + "Auditor" + ] + ] + } + }, + { + "Coordinate": "Query.open", + "Authorization": { + "Authenticated": true, + "Scopes": [], + "Policies": [] + } + }, + { + "Coordinate": "FooInterface", + "Authorization": { + "Authenticated": false, + "Scopes": [], + "Policies": [ + [ + "Admin" + ] + ] + } + }, + { + "Coordinate": "FooEnum", + "Authorization": { + "Authenticated": true, + "Scopes": [], + "Policies": [] + } + }, + { + "Coordinate": "FooScalar", + "Authorization": { + "Authenticated": false, + "Scopes": [ + [ + "scalar" + ] + ], + "Policies": [] + } + } + ] + """); + } + + [Fact] + public void Create_Should_SetHasAuthorization_When_FieldCarriesOrInheritsFusionAuthorization() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var query = schema.Types.GetType("Query"); + var iface = schema.Types.GetType("FooInterface"); + + // assert + new + { + Secured = query.Fields["secured"].HasAuthorization, + Open = query.Fields["open"].HasAuthorization, + InterfaceField = iface.Fields["id"].HasAuthorization + }.MatchInlineSnapshot( + """ + { + "Secured": true, + "Open": true, + "InterfaceField": true + } + """); + } + + [Fact] + public void Create_Should_SetHasAuthorization_When_InterfaceFieldCarriesFusionAuthorization() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + node: Node + } + + interface Node { + id: Int @authenticated + name: String + } + + type Item implements Node { + id: Int @authenticated + name: String + } + + {{Directives}} + """); + + // act + var node = schema.Types.GetType("Node"); + + // assert + new + { + Id = node.Fields["id"].HasAuthorization, + Name = node.Fields["name"].HasAuthorization + }.MatchInlineSnapshot( + """ + { + "Id": true, + "Name": false + } + """); + } + + [Fact] + public void Create_Should_CollectAuthorizationUsage_When_SchemaUsesAuthorization() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var usage = schema.Features.Get()!; + + // assert + new + { + usage.IsUsed, + usage.UsesAuthenticated, + usage.UsesScopes, + usage.UsesPolicies, + usage.PolicyNames + }.MatchInlineSnapshot( + """ + { + "IsUsed": true, + "UsesAuthenticated": true, + "UsesScopes": true, + "UsesPolicies": true, + "PolicyNames": [ + "Admin", + "Auditor", + "Editor" + ] + } + """); + } + + [Fact] + public void Create_Should_ReportNoAuthorizationUsage_When_SchemaHasNoAuthorization() + { + // arrange + var schema = ComposeSchema( + """ + type Query { + field: Int + } + """); + + // act + var usage = schema.Features.Get()!; + + // assert + new + { + usage.IsUsed, + usage.UsesAuthenticated, + usage.UsesScopes, + usage.UsesPolicies, + usage.PolicyNames + }.MatchInlineSnapshot( + """ + { + "IsUsed": false, + "UsesAuthenticated": false, + "UsesScopes": false, + "UsesPolicies": false, + "PolicyNames": [] + } + """); + } + + [Fact] + public void Create_Should_HideFusionAuthorization_When_LoadingSchema() + { + // arrange + var schema = ComposeSchema(SchemaA, SchemaB); + + // act + var query = schema.Types.GetType("Query"); + + // assert + new + { + DefinitionLoaded = schema.DirectiveDefinitions.ContainsName("fusion__authorization"), + TypeDirectives = query.Directives.WithInternals.AsEnumerable().Select(static d => d.Name).ToArray(), + FieldDirectives = query.Fields["secured"].Directives.WithInternals + .AsEnumerable() + .Select(static d => d.Name) + .ToArray() + }.MatchInlineSnapshot( + """ + { + "DefinitionLoaded": false, + "TypeDirectives": [], + "FieldDirectives": [] + } + """); + } + + [Fact] + public void Parse_Should_Throw_When_ScopesIsNotAListOfLists() + { + // arrange + var directive = Utf8GraphQLParser.Syntax + .ParseObjectTypeDefinition("type Foo @fusion__authorization(scopes: [\"read\"]) { a: Int }") + .Directives[0]; + + // act + var exception = Record.Exception(() => AuthorizationDirectiveParser.Parse(directive)); + + // assert + Assert.IsType(exception); + Assert.Equal( + "The `scopes` argument of @fusion__authorization must be a list of string lists.", + exception.Message); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs index dd2a8e4d094..5eb305d0a69 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/FusionTestBase.cs @@ -3,6 +3,7 @@ using System.Text.Json; using HotChocolate.Buffers; using HotChocolate.Execution.Configuration; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Configuration; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Nodes.Serialization; @@ -504,10 +505,27 @@ public TestServer CreateSourceSchema( configureApplication); } + protected static OperationPlan PlanOperation( + FusionSchemaDefinition schema, + [StringSyntax("graphql")] string operationText, + IPolicyResolver policyResolver) + => PlanOperation(schema, operationText, OperationPlannerOptions.Default, policyResolver); + protected static OperationPlan PlanOperation( FusionSchemaDefinition schema, [StringSyntax("graphql")] string operationText, OperationPlannerOptions? options = null) + => PlanOperation( + schema, + operationText, + options ?? OperationPlannerOptions.Default, + TestPolicyResolver.Create()); + + private static OperationPlan PlanOperation( + FusionSchemaDefinition schema, + string operationText, + OperationPlannerOptions options, + IPolicyResolver policyResolver) { var pool = new DefaultObjectPool>>( new DefaultPooledObjectPolicy>>()); @@ -519,9 +537,7 @@ protected static OperationPlan PlanOperation( var operation = rewritten.Definitions.OfType().First(); var compiler = new OperationCompiler(schema, pool); - var planner = options is null - ? new OperationPlanner(schema, compiler) - : new OperationPlanner(schema, compiler, options); + var planner = new OperationPlanner(schema, compiler, options, policyResolver); const string id = "123456789101112"; return planner.CreatePlan(id, id, id, operation); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AuthorizationPlanningTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AuthorizationPlanningTests.cs new file mode 100644 index 00000000000..b7ae0fda63e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/AuthorizationPlanningTests.cs @@ -0,0 +1,1152 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization; +using HotChocolate.Fusion.Authorization.InMemory; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Execution.Nodes.Serialization; +using HotChocolate.Fusion.Execution.Rewriters; +using HotChocolate.Fusion.Types; +using HotChocolate.Language; +using Microsoft.Extensions.DependencyInjection; +using static HotChocolate.Fusion.Authorization.PolicyTestHelper; + +namespace HotChocolate.Fusion.Planning; + +public class AuthorizationPlanningTests : FusionTestBase +{ + private const string Directives = + """ + directive @authenticated on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @requiresScopes(scopes: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + + directive @policy(policies: [[String!]!]!) + on FIELD_DEFINITION | OBJECT | INTERFACE | ENUM | SCALAR + """; + + private const string ProductSchema = + $$""" + type Query { + product: Product + secret: String @authenticated + } + + type Product { + id: ID! + name: String! @authenticated + price: Int @requiresScopes(scopes: [["read"]]) + } + + {{Directives}} + """; + + private const string ProductsSchemaA = + $$""" + # name: a + type Query { + products: [Product] + } + + type Product @key(fields: "id") { + id: ID! + weight: Int + } + + {{Directives}} + """; + + private const string ProductsSchemaB = + $$""" + # name: b + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + price: Int @authenticated + note: String @requiresScopes(scopes: [["read"]]) + shippingEstimate(weight: Int @require(field: "weight")): Int @authenticated + } + + {{Directives}} + """; + + private const string ProductsSchemaBWithOpenField = + $$""" + # name: b + type Query { + productById(id: ID!): Product @lookup @internal + } + + type Product @key(fields: "id") { + id: ID! + open: String + shippingEstimate(weight: Int @require(field: "weight")): Int @authenticated + } + + {{Directives}} + """; + + [Fact] + public void CreatePlan_Should_SkipProtectedFieldsWithSyntheticVariables_When_FieldsCarryAuthorization() + { + // arrange + var schema = ComposeSchema(ProductSchema); + + // act + var plan = PlanOperation(schema, "{ product { id name price } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1( + $__fusion_auth_1: Boolean! + $__fusion_auth_2: Boolean! + ) { + product { + id + name @skip(if: $__fusion_auth_1) + price @skip(if: $__fusion_auth_2) + } + } + forwardedVariables: + - __fusion_auth_1 + - __fusion_auth_2 + """); + } + + [Fact] + public void CreatePlan_Should_AllocateOneVariablePerSelection_When_AliasesSelectTheSameProtectedField() + { + // arrange + var schema = ComposeSchema(ProductSchema); + + // act + var plan = PlanOperation(schema, "{ product { first: name second: name } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1( + $__fusion_auth_1: Boolean! + $__fusion_auth_2: Boolean! + ) { + product { + first: name @skip(if: $__fusion_auth_1) + second: name @skip(if: $__fusion_auth_2) + } + } + forwardedVariables: + - __fusion_auth_1 + - __fusion_auth_2 + """); + } + + [Fact] + public void CreatePlan_Should_WrapFieldInFragment_When_ProtectedFieldAlreadyHasSkip() + { + // arrange + var schema = ComposeSchema(ProductSchema); + + // act + var plan = PlanOperation(schema, "query($skip: Boolean!) { product { name @skip(if: $skip) } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1($skip: Boolean!, $__fusion_auth_1: Boolean!) { + product { + ... @skip(if: $__fusion_auth_1) { + name @skip(if: $skip) + } + } + } + forwardedVariables: + - skip + - __fusion_auth_1 + """); + } + + [Fact] + public void CreatePlan_Should_MoveSharedVariableToNodeCondition_When_AllRootSelectionsAreProtected() + { + // arrange + var schema = ComposeSchema(ProductsSchemaA, ProductsSchemaB); + + // act + var plan = PlanOperation(schema, "{ products { price } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + products { + id + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + price + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + conditions: + - variable: $__fusion_auth_1 + passingValue: false + dependencies: + - id: 1 + """); + } + + [Fact] + public void CreatePlan_Should_CreateNodeVariable_When_RootSelectionsHaveDifferentVariables() + { + // arrange + var schema = ComposeSchema(ProductsSchemaA, ProductsSchemaB); + + // act + var plan = PlanOperation(schema, "{ products { price note } }"); + + // assert + Describe(plan).MatchInlineSnapshot( + """ + { + "Variables": [ + { + "Name": "__fusion_auth_1", + "Selections": [ + "price" + ], + "Operands": [] + }, + { + "Name": "__fusion_auth_2", + "Selections": [ + "note" + ], + "Operands": [] + }, + { + "Name": "__fusion_auth_3", + "Selections": [], + "Operands": [ + "__fusion_auth_1", + "__fusion_auth_2" + ] + } + ] + } + """); + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + products { + id + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2( + $__fusion_auth_1: Boolean! + $__fusion_auth_2: Boolean! + $__fusion_1_id: ID! + ) { + productById(id: $__fusion_1_id) { + price @skip(if: $__fusion_auth_1) + note @skip(if: $__fusion_auth_2) + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + conditions: + - variable: $__fusion_auth_3 + passingValue: false + forwardedVariables: + - __fusion_auth_1 + - __fusion_auth_2 + dependencies: + - id: 1 + """); + } + + [Fact] + public void CreatePlan_Should_GateRequirementSelection_When_ProtectedFieldIsItsOnlyConsumer() + { + // arrange + var schema = ComposeSchema(ProductsSchemaA, ProductsSchemaB); + + // act + var plan = PlanOperation(schema, "{ products { shippingEstimate } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1($__fusion_auth_1: Boolean!) { + products { + id + weight @skip(if: $__fusion_auth_1) + } + } + forwardedVariables: + - __fusion_auth_1 + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!, $__fusion_2_weight: Int) { + productById(id: $__fusion_1_id) { + shippingEstimate(weight: $__fusion_2_weight) + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_weight + selectionMap: >- + weight + conditions: + - variable: $__fusion_auth_1 + passingValue: false + dependencies: + - id: 1 + """); + } + + [Fact] + public void CreatePlan_Should_NotGateRequirementSelection_When_ConsumerNodeHasDifferentlyProtectedSibling() + { + // arrange + var schema = ComposeSchema(ProductsSchemaA, ProductsSchemaB); + + // act + var plan = PlanOperation(schema, "{ products { shippingEstimate note } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + products { + id + weight + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2( + $__fusion_auth_1: Boolean! + $__fusion_auth_2: Boolean! + $__fusion_1_id: ID! + $__fusion_2_weight: Int + ) { + productById(id: $__fusion_1_id) { + note @skip(if: $__fusion_auth_1) + shippingEstimate(weight: $__fusion_2_weight) @skip(if: $__fusion_auth_2) + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_weight + selectionMap: >- + weight + conditions: + - variable: $__fusion_auth_3 + passingValue: false + forwardedVariables: + - __fusion_auth_1 + - __fusion_auth_2 + dependencies: + - id: 1 + """); + } + + [Fact] + public void CreatePlan_Should_NotGateRequirementSelection_When_ConsumerNodeHasUnprotectedSibling() + { + // arrange + var schema = ComposeSchema(ProductsSchemaA, ProductsSchemaBWithOpenField); + + // act + var plan = PlanOperation(schema, "{ products { shippingEstimate open } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + products { + id + weight + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2( + $__fusion_auth_1: Boolean! + $__fusion_1_id: ID! + $__fusion_2_weight: Int + ) { + productById(id: $__fusion_1_id) { + open + shippingEstimate(weight: $__fusion_2_weight) @skip(if: $__fusion_auth_1) + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_weight + selectionMap: >- + weight + forwardedVariables: + - __fusion_auth_1 + dependencies: + - id: 1 + """); + } + + [Fact] + public void CreatePlan_Should_NotGateRequirementSelection_When_ClientSelectsItToo() + { + // arrange + var schema = ComposeSchema(ProductsSchemaA, ProductsSchemaB); + + // act + var plan = PlanOperation(schema, "{ products { weight shippingEstimate } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + products { + weight + id + } + } + - id: 2 + type: Operation + schema: b + operation: | + query Op_123456789101112_2($__fusion_1_id: ID!, $__fusion_2_weight: Int) { + productById(id: $__fusion_1_id) { + shippingEstimate(weight: $__fusion_2_weight) + } + } + source: $.productById + target: $.products + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_weight + selectionMap: >- + weight + conditions: + - variable: $__fusion_auth_1 + passingValue: false + dependencies: + - id: 1 + """); + } + + [Fact] + public void CreatePlan_Should_ScopeVariableToConcreteType_When_FragmentsSelectTypeConditionedFields() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + search: [SearchResult] + } + + union SearchResult = Book | Movie + + type Book { + title: String @authenticated + } + + type Movie { + title: String + } + + {{Directives}} + """); + + // act + var plan = PlanOperation( + schema, + "{ search { ... on Book { title } ... on Movie { title } } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1($__fusion_auth_1: Boolean!) { + search { + __typename + ... on Book { + title @skip(if: $__fusion_auth_1) + } + ... on Movie { + title + } + } + } + forwardedVariables: + - __fusion_auth_1 + """); + } + + [Fact] + public void CreatePlan_Should_ConditionEachMutationRootField_When_RootFieldsAreProtected() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + open: Int + } + + type Mutation { + delete: Boolean @authenticated + update: Boolean @requiresScopes(scopes: [["write"]]) + create: Boolean + } + + {{Directives}} + """); + + // act + var plan = PlanOperation(schema, "mutation { delete update create }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + mutation Op_123456789101112_1 { + delete + } + conditions: + - variable: $__fusion_auth_1 + passingValue: false + - id: 2 + type: Operation + schema: a + operation: | + mutation Op_123456789101112_2 { + update + } + conditions: + - variable: $__fusion_auth_2 + passingValue: false + - id: 3 + type: Operation + schema: a + operation: | + mutation Op_123456789101112_3 { + create + } + """); + } + + [Fact] + public void CreatePlan_Should_ConditionEventStream_When_SubscriptionFieldIsProtected() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + open: Int + } + + type Subscription { + changed: String @authenticated + } + + {{Directives}} + """); + + // act + var plan = PlanOperation(schema, "subscription { changed }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + subscription Op_123456789101112_1 { + changed + } + conditions: + - variable: $__fusion_auth_1 + passingValue: false + """); + } + + [Fact] + public void CreatePlan_Should_MarkDeferredPlans_When_DeferredFieldsAreProtected() + { + // arrange + var schema = ComposeSchema( + $$""" + # name: a + type Query { + user(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + name: String! + secret: String @authenticated + } + + {{Directives}} + """, + $$""" + # name: b + type Query { + userById(id: ID!): User @lookup + } + + type User @key(fields: "id") { + id: ID! + email: String! @authenticated + nickname: String + } + + {{Directives}} + """); + + // act + var plan = PlanOperation( + schema, + "{ user(id: 1) { name ... @defer { email secret nickname } } }"); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + user(id: 1) { + name + id + } + } + deliveryGroups: + - id: 0 + path: $.user + incrementalPlans: + - deliveryGroupIds: + - 0 + parentNodeId: 1 + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_id + selectionMap: >- + id + nodes: + - id: 1 + type: Operation + schema: b + operation: | + query Op_defer_1($__fusion_auth_1: Boolean!, $__fusion_1_id: ID!) { + userById(id: $__fusion_1_id) { + email @skip(if: $__fusion_auth_1) + nickname + } + } + source: $.userById + target: $.user + requirements: + - name: __fusion_1_id + selectionMap: >- + id + forwardedVariables: + - __fusion_auth_1 + dependencies: + - parentNodeId: 1 + - id: 2 + type: Operation + schema: a + operation: | + query Op_defer_2($__fusion_2_id: ID!) { + user(id: $__fusion_2_id) { + secret + } + } + source: $.user + target: $.user + requirements: + - name: __fusion_2_id + selectionMap: >- + id + conditions: + - variable: $__fusion_auth_2 + passingValue: false + dependencies: + - parentNodeId: 1 + """); + } + + [Fact] + public void CreatePlan_Should_SkipNamesOfTheOperation_When_OperationDeclaresSyntheticVariableName() + { + // arrange + var schema = ComposeSchema(ProductSchema); + + // act + var plan = PlanOperation( + schema, + """ + query($__fusion_auth_1: Boolean = false) { + product { + name @include(if: $__fusion_auth_1) + price + } + } + """); + + // assert + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1( + $__fusion_auth_1: Boolean = false + $__fusion_auth_2: Boolean! + $__fusion_auth_3: Boolean! + ) { + product { + price @skip(if: $__fusion_auth_2) + name @include(if: $__fusion_auth_1) @skip(if: $__fusion_auth_3) + } + } + forwardedVariables: + - __fusion_auth_1 + - __fusion_auth_2 + - __fusion_auth_3 + """); + } + + [Fact] + public void CreatePlan_Should_CollectOneDescriptorPerDirective_When_FieldCombinesDirectives() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + report: String + @authenticated + @requiresScopes(scopes: [["read"], ["admin"]]) + @policy(policies: [["a", "b"], ["c", "a"]]) + } + + {{Directives}} + """); + + // act + var plan = PlanOperation(schema, "{ report }"); + + // assert + plan.Operation.Authorization!.Descriptors + .Select(static d => new + { + d.DirectiveName, + d.PolicyName, + d.Scopes, + Selection = d.Selection.ResponseName + }) + .ToArray() + .MatchInlineSnapshot( + """ + [ + { + "DirectiveName": "authenticated", + "PolicyName": null, + "Scopes": [], + "Selection": "report" + }, + { + "DirectiveName": "requiresScopes", + "PolicyName": null, + "Scopes": [ + [ + "admin" + ], + [ + "read" + ] + ], + "Selection": "report" + }, + { + "DirectiveName": "policy", + "PolicyName": "a", + "Scopes": [], + "Selection": "report" + }, + { + "DirectiveName": "policy", + "PolicyName": "b", + "Scopes": [], + "Selection": "report" + }, + { + "DirectiveName": "policy", + "PolicyName": "c", + "Scopes": [], + "Selection": "report" + } + ] + """); + } + + [Fact] + public void CreatePlan_Should_PinOnePolicyInstance_When_SeveralSelectionsUseTheSamePolicy() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + first: String @policy(policies: [["p"]]) + second: String @policy(policies: [["p"]]) + } + + {{Directives}} + """); + var resolver = CreateResolver(policies => policies.Allow("p")); + + // act + var plan = PlanOperation(schema, "{ first second }", resolver); + + // assert + var descriptors = plan.Operation.Authorization!.Descriptors; + Assert.Equal(2, descriptors.Length); + Assert.Same(descriptors[0].Policy, descriptors[1].Policy); + Assert.IsType(descriptors[0].Policy); + } + + [Fact] + public async Task EvaluateAsync_Should_Throw_When_PolicyOfDescriptorIsNotRegistered() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + field: String @policy(policies: [["missing"]]) + } + + {{Directives}} + """); + var plan = PlanOperation(schema, "{ field }"); + var descriptor = Assert.Single(plan.Operation.Authorization!.Descriptors); + var context = CreateContext( + Authenticated(), + new PolicyEvaluationEntry(descriptor, new Dictionary())); + + // act + var exception = await Assert.ThrowsAsync( + async () => await descriptor.Policy.EvaluateAsync(context, TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "No policy provider knows the policy 'missing' of the directive '@policy'.", + exception.Message); + } + + [Fact] + public void CreatePlan_Should_ProduceSamePlanId_When_PolicyAnswersDiffer() + { + // arrange + var schema = ComposeSchema( + $$""" + type Query { + field: String @policy(policies: [["p"]]) + } + + {{Directives}} + """); + var allowing = CreateResolver(policies => policies.Allow("p")); + var denying = CreateResolver(policies => policies.Deny("p")); + + // act + var allowed = PlanOperation(schema, "{ field }", allowing); + var denied = PlanOperation(schema, "{ field }", denying); + + // assert + Assert.Equal(allowed.Id, denied.Id); + Assert.Equal( + new YamlOperationPlanFormatter().Format(allowed), + new YamlOperationPlanFormatter().Format(denied)); + } + + [Fact] + public void CreatePlan_Should_NotMarkOperation_When_NoSelectionIsProtected() + { + // arrange + var schema = ComposeSchema(ProductSchema); + + // act + var plan = PlanOperation(schema, "{ product { id } }"); + + // assert + Assert.Null(plan.Operation.Authorization); + MatchNodes( + plan, + """ + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_123456789101112_1 { + product { + id + } + } + """); + } + + [Fact] + public void SetAuthorization_Should_Throw_When_PlannerAlreadySetAuthorization() + { + // arrange + var schema = ComposeSchema(ProductSchema); + var plan = PlanOperation(schema, "{ product { id } }"); + + // act + var exception = Assert.Throws( + () => plan.Operation.SetAuthorization(null)); + + // assert + Assert.Equal( + "The authorization of the operation was already set and cannot be set again.", + exception.Message); + } + + [Fact] + public void CreatePlan_Should_FlagProtectedSelections_When_FieldsCarryAuthorization() + { + // arrange + var schema = ComposeSchema(ProductSchema); + + // act + var plan = PlanOperation(schema, "{ product { id name price } secret }"); + + // assert + var product = plan.Operation.RootSelectionSet.Selections[0]; + new + { + Root = plan.Operation.RootSelectionSet.Selections.ToArray() + .Select(static s => new { s.ResponseName, s.HasAuthorization }), + Product = product.GetSelectionSet()!.Selections.ToArray() + .Select(static s => new { s.ResponseName, s.HasAuthorization }) + }.MatchInlineSnapshot( + """ + { + "Root": [ + { + "ResponseName": "product", + "HasAuthorization": false + }, + { + "ResponseName": "secret", + "HasAuthorization": true + } + ], + "Product": [ + { + "ResponseName": "id", + "HasAuthorization": false + }, + { + "ResponseName": "name", + "HasAuthorization": true + }, + { + "ResponseName": "price", + "HasAuthorization": true + } + ] + } + """); + } + + [Fact] + public async Task CreatePlan_Should_UseRegisteredPolicies_When_PlannerIsCreatedByTheGateway() + { + // arrange + var schemaDocument = ComposeSchemaDocument( + $$""" + type Query { + field: String @policy(policies: [["p"]]) + } + + {{Directives}} + """); + var services = new ServiceCollection(); + services + .AddGraphQLGateway() + .AddInMemoryPolicies(policies => policies.Allow("p")) + .AddInMemoryConfiguration(schemaDocument) + .ConfigureSchemaServices( + (_, sc) => sc.AddSingleton( + new TestAuthenticationSchemeLookup("Bearer"))); + IServiceProvider serviceProvider = services.BuildServiceProvider(); + var executor = await serviceProvider.GetRequestExecutorAsync( + cancellationToken: TestContext.Current.CancellationToken); + var schema = executor.Schema.Services.GetRequiredService(); + var planner = executor.Schema.Services.GetRequiredService(); + var operation = new DocumentRewriter(schema) + .RewriteDocument(Utf8GraphQLParser.Parse("{ field }"), operationName: null) + .Definitions.OfType() + .First(); + + // act + var plan = planner.CreatePlan("1", "1", "1", operation, TestContext.Current.CancellationToken); + + // assert + var descriptor = Assert.Single(plan.Operation.Authorization!.Descriptors); + Assert.IsType(descriptor.Policy); + } + + private static IPolicyResolver CreateResolver(Action configure) + { + var builder = new InMemoryPolicyBuilder(); + configure(builder); + + return new PolicyResolver( + new BuiltInPolicyProvider(), + [builder.Build(new InMemoryPolicyRecorder())]); + } + + private static object Describe(OperationPlan plan) + { + var authorization = plan.Operation.Authorization!; + + return new + { + Variables = authorization.Variables.Select( + static v => new + { + v.Name, + Selections = v.Selections.Select(static s => s.ResponseName), + Operands = v.Operands.Select(static o => o.Name) + }) + }; + } + + private static void MatchNodes(OperationPlan plan, [StringSyntax("yaml")] string expected) + { + var yaml = new YamlOperationPlanFormatter().Format(plan); + var actual = yaml[yaml.IndexOf("nodes:", StringComparison.Ordinal)..]; + actual.MatchInlineSnapshot(expected + Environment.NewLine); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs index 55317fd641b..d9455ecfa03 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/GeneratedOperationNameTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types; using HotChocolate.Language; @@ -54,7 +55,10 @@ private static OperationPlan CreatePlan(FusionSchemaDefinition schema, string sh { var pool = new DefaultObjectPool>>( new DefaultPooledObjectPolicy>>()); - var planner = new OperationPlanner(schema, new OperationCompiler(schema, pool)); + var planner = new OperationPlanner( + schema, + new OperationCompiler(schema, pool), + TestPolicyResolver.Create()); return planner.CreatePlan( "operation-id", diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationMergePolicyTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationMergePolicyTests.cs index 3b33638a086..b8dd6a2646f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationMergePolicyTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationMergePolicyTests.cs @@ -1,4 +1,5 @@ using System.Text; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Rewriters; using HotChocolate.Fusion.Types; @@ -224,7 +225,8 @@ private static OperationPlan PlanOperation( new OperationPlannerOptions { MergePolicy = mergePolicy - }); + }, + TestPolicyResolver.Create()); const string id = "123456789101112"; return planner.CreatePlan(id, id, id, operation); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs index ec8c30818a0..20e8716b91d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerBatchingGroupIdTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Execution.Nodes.Serialization; using HotChocolate.Fusion.Execution.Rewriters; @@ -293,7 +294,8 @@ private static OperationPlan PlanOperation( new OperationPlannerOptions { EnableRequestGrouping = enableRequestGrouping - }); + }, + TestPolicyResolver.Create()); const string id = "123456789101112"; return planner.CreatePlan(id, id, id, operation); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCancellationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCancellationTests.cs index d6cf34d7680..52182a8b906 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCancellationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCancellationTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types; using HotChocolate.Language; @@ -53,7 +54,7 @@ private static OperationPlanner CreatePlanner(FusionSchemaDefinition schema) new DefaultPooledObjectPolicy>>()); var compiler = new OperationCompiler(schema, pool); - return new OperationPlanner(schema, compiler); + return new OperationPlanner(schema, compiler, TestPolicyResolver.Create()); } private static OperationDefinitionNode ParseOperation([StringSyntax("graphql")] string operationText) diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs index 84ec2e8b0de..2bfcfd1c379 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerCostModelTests.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using HotChocolate.Execution; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; using Microsoft.Extensions.ObjectPool; @@ -44,7 +45,7 @@ public void Constructors_Wire_Default_And_Custom_Options() new DefaultPooledObjectPolicy>>()); var compiler = new OperationCompiler(schema, pool); - var defaultPlanner = new OperationPlanner(schema, compiler); + var defaultPlanner = new OperationPlanner(schema, compiler, TestPolicyResolver.Create()); Assert.Equal(15.0, defaultPlanner.Options.DepthWeight); Assert.Equal(1.5, defaultPlanner.Options.OperationWeight); @@ -59,7 +60,7 @@ public void Constructors_Wire_Default_And_Custom_Options() FanoutPenaltyThreshold = 4 }; - var customPlanner = new OperationPlanner(schema, compiler, customOptions); + var customPlanner = new OperationPlanner(schema, compiler, customOptions, TestPolicyResolver.Create()); Assert.Equal(customOptions.DepthWeight, customPlanner.Options.DepthWeight); Assert.Equal(customOptions.OperationWeight, customPlanner.Options.OperationWeight); diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs index fdc34c9d502..2ede54b7353 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/OperationPlannerGuardrailTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types; using HotChocolate.Language; @@ -130,7 +131,7 @@ private static OperationPlanner CreatePlanner( new DefaultPooledObjectPolicy>>()); var compiler = new OperationCompiler(schema, pool); - return new OperationPlanner(schema, compiler, options); + return new OperationPlanner(schema, compiler, options, TestPolicyResolver.Create()); } private static OperationDefinitionNode ParseOperation([StringSyntax("graphql")] string operationText) diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/PlannerEventSourceTests.cs b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/PlannerEventSourceTests.cs index 73dfd841e43..15256df095d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/PlannerEventSourceTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Planning/PlannerEventSourceTests.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; +using HotChocolate.Fusion.Authorization; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Fusion.Types; using HotChocolate.Language; @@ -215,8 +216,8 @@ private static OperationPlanner CreatePlanner( var compiler = new OperationCompiler(schema, pool); return options is null - ? new OperationPlanner(schema, compiler) - : new OperationPlanner(schema, compiler, options); + ? new OperationPlanner(schema, compiler, TestPolicyResolver.Create()) + : new OperationPlanner(schema, compiler, options, TestPolicyResolver.Create()); } private static OperationPlan CreatePlan( diff --git a/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs b/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs index 5a3098a59b1..d02df192380 100644 --- a/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs +++ b/src/HotChocolate/Primitives/src/Primitives/Types/DirectiveNames.cs @@ -257,6 +257,61 @@ public static class Inaccessible public const string Name = "inaccessible"; } + /// + /// The name constants of the @authenticated directive. + /// + public static class Authenticated + { + /// + /// The name of the @authenticated directive. + /// + public const string Name = "authenticated"; + } + + /// + /// The name constants of the @requiresScopes directive. + /// + public static class RequiresScopes + { + /// + /// The name of the @requiresScopes directive. + /// + public const string Name = "requiresScopes"; + + /// + /// The argument names of the @requiresScopes directive. + /// + public static class Arguments + { + /// + /// The name of the @requiresScopes scopes argument. + /// + public const string Scopes = "scopes"; + } + } + + /// + /// The name constants of the @policy directive. + /// + public static class Policy + { + /// + /// The name of the @policy directive. + /// + public const string Name = "policy"; + + /// + /// The argument names of the @policy directive. + /// + public static class Arguments + { + /// + /// The name of the @policy policies argument. + /// + public const string Policies = "policies"; + } + } + /// /// The name constants of the @key directive. /// diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs index d339440fd94..7067c43afda 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs @@ -5237,6 +5237,16 @@ directive @cacheControl( vary: [String] ) on OBJECT | FIELD_DEFINITION | INTERFACE | UNION + "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." + directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] + ) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." @@ -5437,6 +5447,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet + "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." + directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] + ) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." @@ -5743,6 +5763,16 @@ directive @cacheControl( vary: [String] ) on OBJECT | FIELD_DEFINITION | INTERFACE | UNION + "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." + directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] + ) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs index 9e773c148c0..938d36d0ff2 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionValidateCommandTests.cs @@ -1337,6 +1337,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet + "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." + directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] + ) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." @@ -1571,6 +1581,16 @@ directive @cacheControl( vary: [String] ) on OBJECT | FIELD_DEFINITION | INTERFACE | UNION + "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." + directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] + ) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." @@ -1814,6 +1834,16 @@ directive @cacheControl( vary: [String] ) on OBJECT | FIELD_DEFINITION | INTERFACE | UNION + "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." + directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] + ) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls index c56f8e2ff17..ed58e01ee0c 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-example-1-result/composite-schema.graphqls @@ -39,6 +39,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet +"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." +directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] +) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls index c56f8e2ff17..ed58e01ee0c 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-exclude-by-tag-result/composite-schema.graphqls @@ -39,6 +39,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet +"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." +directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] +) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls index c04160dd82b..b260feacd18 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/__resources__/valid-extensions-result/composite-schema.graphqls @@ -51,6 +51,16 @@ scalar fusion__FieldSelectionPath "The fusion__FieldSelectionSet scalar is used to represent a GraphQL selection set. To simplify the syntax, the outermost selection set is not wrapped in curly braces." scalar fusion__FieldSelectionSet +"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." +directive @fusion__authorization( + "Whether the member requires an authenticated user." + authenticated: Boolean! = false + "The alternative policy groups, of which one must be fully satisfied. Each group is a set of policy names that must all pass." + policies: [[String!]!] + "The alternative scope groups, of which one must be fully granted. Each group is a set of scopes that must all be present." + scopes: [[String!]!] +) on SCALAR | OBJECT | FIELD_DEFINITION | INTERFACE | ENUM + "The @fusion__cost directive specifies cost metadata for each source schema." directive @fusion__cost( "The name of the source schema that defined the cost metadata." diff --git a/website/content/docs/fusion/composition.md b/website/content/docs/fusion/composition.md index 33846687ced..f677a4fb598 100644 --- a/website/content/docs/fusion/composition.md +++ b/website/content/docs/fusion/composition.md @@ -50,6 +50,8 @@ Parses each source schema into an internal representation. This phase catches sy Normalizes each source schema for composition. This includes applying tag-based exclusions and any version-specific or interop transformations needed before the validation phases run. +In an Apollo Federation source schema, an `@authenticated`, `@requiresScopes` or `@policy` directive that is not imported through `@link` is treated as the authorization directive when its local definition is compatible or absent. An incompatible local definition fails composition. + ## 3. Enrich Source Schemas Decorates each schema with metadata extracted from directives, such as key fields, shareability, accessibility flags, and lookup information. The metadata is attached to types and fields so later phases can reason about them efficiently without re-parsing directive arguments. @@ -68,7 +70,7 @@ Combines all validated source schemas into a single merged schema. Same-named ty ## 7. Post-Merge Validation -Validates the merged schema as a whole. The rules here treat the composed schema as if it were a single GraphQL schema and check global invariants: no merged type is empty, all interfaces are implemented, no field references an inaccessible or internal type, the root query type exposes accessible fields, and default values do not point at inaccessible enum values. +Validates the merged schema as a whole. The rules here treat the composed schema as if it were a single GraphQL schema and check global invariants: no merged type is empty, all interfaces are implemented, no field references an inaccessible or internal type, the root query type exposes accessible fields, and default values do not point at inaccessible enum values. It also checks that a field reading other fields through `@require` or a lookup key declares every authorization requirement of those fields. ## 8. Validate Satisfiability @@ -235,6 +237,8 @@ The placeholders `{0}`, `{1}`, etc. in the message column are replaced with the | Code | Message | How to resolve | | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AUTHORIZATION_ON_INTERFACE_OBJECT` | The @interfaceObject type '\{0\}' in schema '\{1\}' cannot be annotated with @\{2\}. Annotate its fields instead. | `@authenticated`, `@requiresScopes` and `@policy` are not accepted on an `@interfaceObject` type. Move the directive onto the fields of the type, where it expands onto the matching fields of every implementing type. | +| `AUTHORIZATION_TRANSITIVE_REQUIREMENTS_MISSING` | The field '\{0\}' depends on '\{1\}' through '\{2\}' in schema '\{3\}', but does not declare all authorization requirements of '\{1\}'. Not covered: \{4\}. | A field that reads other fields through `@require` or through the key of a lookup must declare every authorization requirement of the fields it reads. Requirements are compared after interface and type inheritance has been applied to every field. Add `@authenticated` when a dependency has it, and make every alternative group of `@requiresScopes` and `@policy` contain an alternative group of the dependency, for example `[["read", "pii"]]` covers `[["read"]]`. The error cannot be downgraded to a warning. | | `CONFLICTING_SOURCE_SCHEMA_NAME` | '\{0\}' conflicts with the existing source schema name '\{1\}'. Either rename '\{0\}' to '\{1\}' if they're the same, or rename '\{0\}' to something else if they're different. | Two source schemas were registered under names that the composition treats as the same identity. Rename one of them in your composition configuration so each source schema has a unique name. | | [DISALLOWED_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Disallowed-Inaccessible-Elements) | (Message varies by element kind: built-in scalar, introspection type, introspection field, introspection argument, or built-in directive argument.) | Built-in scalars, introspection types and their members, and built-in directive arguments cannot be marked `@inaccessible`. Remove the directive from the offending element in the named source schema. | | [EMPTY_MERGED_ENUM_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Enum-Type) | The merged enum type '\{0\}' is empty. | Every value of the enum was excluded by `@inaccessible` or tag filters, leaving nothing to merge. Either expose at least one enum value or remove the type entirely. | @@ -315,13 +319,17 @@ The placeholders `{0}`, `{1}`, etc. in the message column are replaced with the ## Warnings -| Code | Message | How to resolve | -| -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [LOOKUP_RETURNS_NON_NULLABLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-Non-Nullable-Type) | The lookup field '\{0\}' in schema '\{1\}' should return a nullable type. | Lookups should return nullable entities so the gateway can represent missing keys without throwing. Change the return type of the lookup field to nullable. See [Entities and Lookups](./entities-and-lookups.md). | -| `SOURCE_SCHEMA_DEV_URL_MISSING` | The source schema '\{0\}' does not specify a 'devUrl' for its HTTP transport. The composed configuration uses its 'url', which might not be reachable from the local development environment. | Emitted only when composing for a local Aspire run, never by `nitro fusion compose` or `publish`. A source schema that is not running in the local AppHost defines no `devUrl`, so the deployed `url` is used. See [What Happens When a Gateway Starts](./local-development.md#what-happens-when-a-gateway-starts). | -| `SOURCE_SCHEMA_URL_VARIABLE_UNRESOLVED` | The '\{0\}' of the source schema '\{1\}' contains variables that are not defined for the environment '\{2\}'. \{3\} | Emitted only when composing for a local Aspire run, never by `nitro fusion compose` or `publish`. A `\{\{VARIABLE\}\}` in the source schema's `url` or `devUrl` could not be resolved against the selected environment, so composition fell back leniently instead of failing. See [What Happens When a Gateway Starts](./local-development.md#what-happens-when-a-gateway-starts). | -| [SPECIFIED_BY_URL_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-SpecifiedBy-URL-Mismatch) | The scalar type '\{0\}' has a different specified-by URL in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | The same scalar declares conflicting `@specifiedBy(url: ...)` URLs in different source schemas. Align the URL across all source schemas that define the scalar so the composed schema points to a single specification. | -| [UNSATISFIABLE_QUERY_PATH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Unsatisfiable-Query-Path) | A source-schema `Node` dispatcher does not cover a composite `Node` type. | In source-schema node-resolution mode, Fusion allows the source resolver to report the uncovered type as `null` or an error at runtime. Confirm that this compatibility behavior is intended. | +| Code | Message | How to resolve | +| -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AUTHENTICATED_MISMATCH` | The member '\{0\}' is marked with @authenticated in the source schemas \{1\} but not in the source schemas \{2\}. The composed member requires authentication. | Source schemas disagree on `@authenticated` for a shared type or field. Any source schema that marks the member makes the composed member require an authenticated user. Add `@authenticated` to the remaining source schemas to make the intent explicit. | +| `AUTHORIZATION_GROUP_COUNT_EXCEEDED` | The merged \{0\} requirement of '\{1\}' has \{2\} alternative groups, which exceeds the threshold of \{3\}. | Combining the `@requiresScopes` or `@policy` requirements of several source schemas produced a large number of alternative groups, because every combination of the source requirements is kept. Reduce the number of alternatives declared in the source schemas. | +| `AUTHORIZATION_INHERITED` | The member '\{0\}' requires authorization through interface inheritance, but no source schema annotated it. The requirement comes from \{1\} in the source schemas \{2\}. | A requirement declared on an interface, an interface field, or the same-named field of an implementing type was inherited by this member. Interface requirements apply to every implementing type and field, and a field requirement on an implementing type applies to the interface field and to the other implementations of that field. Annotate the member explicitly to make the intent visible, or review the listed source schemas. | +| `INVALID_AUTHORIZATION_DIRECTIVE_ARGUMENT` | 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 `scopes` argument of `@requiresScopes` or the `policies` argument of `@policy` is missing, `null` or has a shape other than a string, a list of strings or a list of string lists. The argument is also rejected when it is an empty list such as `[]`, contains an empty group such as `[[]]`, or contains an empty or whitespace-only string. Pass a string such as `"read"`, a list of alternatives such as `["read", "write"]`, or a list of groups such as `[["read", "write"]]`. | +| [LOOKUP_RETURNS_NON_NULLABLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-Non-Nullable-Type) | The lookup field '\{0\}' in schema '\{1\}' should return a nullable type. | Lookups should return nullable entities so the gateway can represent missing keys without throwing. Change the return type of the lookup field to nullable. See [Entities and Lookups](./entities-and-lookups.md). | +| `SOURCE_SCHEMA_DEV_URL_MISSING` | The source schema '\{0\}' does not specify a 'devUrl' for its HTTP transport. The composed configuration uses its 'url', which might not be reachable from the local development environment. | Emitted only when composing for a local Aspire run, never by `nitro fusion compose` or `publish`. A source schema that is not running in the local AppHost defines no `devUrl`, so the deployed `url` is used. See [What Happens When a Gateway Starts](./local-development.md#what-happens-when-a-gateway-starts). | +| `SOURCE_SCHEMA_URL_VARIABLE_UNRESOLVED` | The '\{0\}' of the source schema '\{1\}' contains variables that are not defined for the environment '\{2\}'. \{3\} | Emitted only when composing for a local Aspire run, never by `nitro fusion compose` or `publish`. A `\{\{VARIABLE\}\}` in the source schema's `url` or `devUrl` could not be resolved against the selected environment, so composition fell back leniently instead of failing. See [What Happens When a Gateway Starts](./local-development.md#what-happens-when-a-gateway-starts). | +| [SPECIFIED_BY_URL_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-SpecifiedBy-URL-Mismatch) | The scalar type '\{0\}' has a different specified-by URL in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | The same scalar declares conflicting `@specifiedBy(url: ...)` URLs in different source schemas. Align the URL across all source schemas that define the scalar so the composed schema points to a single specification. | +| [UNSATISFIABLE_QUERY_PATH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Unsatisfiable-Query-Path) | A source-schema `Node` dispatcher does not cover a composite `Node` type. | In source-schema node-resolution mode, Fusion allows the source resolver to report the uncovered type as `null` or an error at runtime. Confirm that this compatibility behavior is intended. | ## Diagnosing UNSATISFIABLE_QUERY_PATH Diagnostics