diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ApplyPolicyMutableEnumTypeDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ApplyPolicyMutableEnumTypeDefinition.cs new file mode 100644 index 00000000000..a08cdb02170 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/ApplyPolicyMutableEnumTypeDefinition.cs @@ -0,0 +1,19 @@ +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/AuthorizeMutableDirectiveDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthorizeMutableDirectiveDefinition.cs new file mode 100644 index 00000000000..b27b1494e14 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Definitions/AuthorizeMutableDirectiveDefinition.cs @@ -0,0 +1,47 @@ +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/DirectiveMergers/AuthorizeDirectiveMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizeDirectiveMerger.cs new file mode 100644 index 00000000000..022de49f258 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/DirectiveMergers/AuthorizeDirectiveMerger.cs @@ -0,0 +1,136 @@ +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 new file mode 100644 index 00000000000..5c5aeab1527 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/ApplyPolicy.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Directives; + +internal enum ApplyPolicy +{ + BeforeResolver = 0, + AfterResolver = 1, + Validation = 2 +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizeDirective.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizeDirective.cs new file mode 100644 index 00000000000..bfbd37d79de --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Directives/AuthorizeDirective.cs @@ -0,0 +1,69 @@ +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/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs index 41742e73a9b..d34e4766422 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -59,6 +59,42 @@ internal CompositionResources() { } } + /// + /// Looks up a localized string similar to The 'apply' argument of the @authorize directive must be of type ApplyPolicy.. + /// + internal static string AuthorizeDirective_ApplyArgument_Invalid { + get { + return ResourceManager.GetString("AuthorizeDirective_ApplyArgument_Invalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The value '{0}' for argument 'apply' in the @authorize directive is invalid.. + /// + internal static string AuthorizeDirective_ApplyArgument_InvalidEnumValue { + get { + return ResourceManager.GetString("AuthorizeDirective_ApplyArgument_InvalidEnumValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The 'policy' argument of the @authorize directive must be of type String.. + /// + internal static string AuthorizeDirective_PolicyArgument_Invalid { + 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); + } + } + /// /// Looks up a localized string similar to {0} (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 86600bbda43..206ffcea0d6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -18,6 +18,18 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + The 'apply' argument of the @authorize directive must be of type ApplyPolicy. + + + The value '{0}' for argument 'apply' in the @authorize directive is invalid. + + + The 'policy' argument of the @authorize directive must be of type String. + + + The 'roles' argument of the @authorize directive must be of type [String!]. + {0} (Schema: '{1}') diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs index 3803f7bdbc9..8d729996342 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SourceSchemaMerger.cs @@ -50,6 +50,10 @@ public SourceSchemaMerger( _directiveMergers = new Dictionary { + { + DirectiveNames.Authorize, + new AuthorizeDirectiveMerger(DirectiveMergeBehavior.Include) + }, { DirectiveNames.CacheControl, new CacheControlDirectiveMerger(_options.CacheControlMergeBehavior) @@ -738,6 +742,8 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( () => { var memberDefinitions = typeGroup.Select(g => new DirectivesProviderInfo(g.Type, g.Schema)).ToImmutableArray(); + _directiveMergers[DirectiveNames.Authorize] + .MergeDirectives(objectType, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.CacheControl] .MergeDirectives(objectType, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.Cost] @@ -862,6 +868,8 @@ private MutableInterfaceTypeDefinition MergeInterfaceTypes( { var memberDefinitions = fieldGroup.Select(g => new DirectivesProviderInfo(g.Field, g.Schema)).ToImmutableArray(); + _directiveMergers[DirectiveNames.Authorize] + .MergeDirectives(outputField, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.CacheControl] .MergeDirectives(outputField, memberDefinitions, mergedSchema); _directiveMergers[DirectiveNames.Cost] diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs index aa6e7fdf047..1365fe71d0f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownArgumentNames.cs @@ -2,6 +2,7 @@ namespace HotChocolate.Fusion; internal static class WellKnownArgumentNames { + public const string Apply = "apply"; public const string AssumedSize = "assumedSize"; public const string DestructiveHint = "destructiveHint"; public const string Field = "field"; @@ -21,9 +22,11 @@ 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 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 Scope = "scope"; public const string SharedMaxAge = "sharedMaxAge"; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs index e10d480f8fa..cf746ac7cea 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownDirectiveNames.cs @@ -4,6 +4,7 @@ namespace HotChocolate.Fusion; internal static class WellKnownDirectiveNames { + public const string Authorize = "authorize"; public const string CacheControl = DirectiveNames.CacheControl.Name; public const string Cost = DirectiveNames.Cost.Name; public const string External = DirectiveNames.External.Name; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs index eba162f9418..f736c66123b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/WellKnownTypeNames.cs @@ -2,6 +2,7 @@ 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/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizeDirective.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizeDirective.Tests.cs new file mode 100644 index 00000000000..85fe0fb7021 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.AuthorizeDirective.Tests.cs @@ -0,0 +1,493 @@ +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); + }; +}