Skip to content

Commit 13e0479

Browse files
lukemurrayclaude
andcommitted
User-filtered SDL: keep the output self-consistent, share it with introspection
Review of #571 found filtered SDL could name types it never defines: a type emptied by field-level auth, a protected interface in implements, a visible field with a protected argument type, and an empty query root. SchemaVisibility now computes the hidden types once per request context and repeats until stable: a type the filtering empties is hidden, a field is hidden if it is protected or its return or any argument type is hidden, and hidden interfaces/union members are filtered from their lists. The query root always stays (output with no field block when empty). Introspection uses the same set, so __schema, __type and SDL agree. Also: default interface methods on ISchemaProvider so implementers do not break, ToGraphQLSchemaStringAsync(ClaimsPrincipal) using the schema's own AuthorizationService, the SchemaIntrospection.Make doc comment fixed, and tests that assert every referenced type is defined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cb75feb commit 13e0479

8 files changed

Lines changed: 439 additions & 89 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
## Changes
44

5-
- `ToGraphQLSchemaString(QueryRequestContext? requestContext, bool includeDescriptions = true)` outputs SDL containing only the types and fields the user in the request context is authorized to access - the same rules introspection applies (protected types, and fields that are protected or return a protected type, are left out; the mutation/subscription root is omitted when none of its fields are visible). Useful for handing a schema to a user or AI agent (e.g. an MCP server tool) where SDL is more compact than introspection. `ToGraphQLSchemaString()` without a context still outputs the full schema.
5+
- `ToGraphQLSchemaStringAsync(ClaimsPrincipal? user)` (and `ToGraphQLSchemaString(QueryRequestContext? requestContext)`) output SDL containing only what that user may access, using the schema's `AuthorizationService` as query execution does. The result is self-consistent: a type the filtering leaves empty is hidden along with the fields returning it, a field is hidden when an argument's type is hidden, and hidden interfaces and union members are dropped from `implements` and union lists. Useful for handing a schema to a user or AI agent (e.g. an MCP server tool) where SDL is more compact than introspection. `ToGraphQLSchemaString()` without a user still outputs the full schema. Both are default interface methods on `ISchemaProvider`, so custom implementations do not break.
6+
- Introspection for a user now hides the same things the SDL does. Previously a type with no type-level authorization but every field protected was still listed (with `fields: []`), a field taking an argument of a protected enum or input type was listed, and protected interfaces still appeared in `interfaces`. These are now hidden, so a client building a schema from introspection gets a valid one. Introspection without a user is unchanged.
67
- `[Required]` on an argument (a property/field of an arguments object or a method parameter) now behaves like `ArgumentHelper.Required<T>()` - the argument has no default value and must be provided by the query. Previously a value type kept its implicit `default(T)` as the schema default, so `[Required] int Take` printed `take: Int! = 0` (optional for clients) and a query leaving it out ran with `0`. It is now `take: Int!` and leaving it out is a `missing required argument` error. Remove `[Required]` if you relied on the old default.
78
- Introspection `__InputValue.defaultValue` no longer strips the quotes off string-like defaults. The spec defines it as the GraphQL literal, so a string default of `hello` is now returned as `"hello"` - the same text as in the SDL. Clients building a schema from introspection (`buildClientSchema`, GraphiQL, codegen against an endpoint) previously dropped such defaults or failed to parse them (`Guid`, strings with spaces). If you read `defaultValue` directly as text rather than parsing it as GraphQL, string defaults now include their quotes.
89

‎docs/docs/authorization.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,14 @@ Introspection queries (`__schema` and `__type`) respect authorization — the re
6565

6666
`schema.ToGraphQLSchemaString()` always outputs the full schema — use it to generate SDL files for tooling and type generation.
6767

68-
To get SDL filtered for a user, pass a `QueryRequestContext`. The same rules as introspection apply: protected types are left out, as are fields that are protected or return a protected type.
68+
To get SDL filtered for a user, use `ToGraphQLSchemaStringAsync(user)`. It uses the schema's `AuthorizationService` exactly as query execution does, and applies the same rules as introspection.
6969

7070
```cs
71-
var requestContext = new QueryRequestContext(await schema.AuthorizationService.PrepareForRequestAsync(schema, user), user);
72-
var sdl = schema.ToGraphQLSchemaString(requestContext);
71+
var sdl = await schema.ToGraphQLSchemaStringAsync(user);
7372
```
7473

74+
The result is self-consistent SDL: nothing in it refers to a type it does not define. Protected types are left out, as are fields that are protected, return a hidden type or take an argument of a hidden type. A type whose fields are all hidden is itself hidden, which can in turn hide the fields returning it. Protected interfaces are dropped from `implements` lists, and protected types from union member lists. The query root always stays, even with no visible fields. Introspection hides exactly the same types and fields, so `__schema`, `__type` and the SDL agree.
75+
7576
This is useful when handing a schema to a person or an AI agent (e.g. an MCP server tool), where fields they cannot query are noise or information they should not have. SDL is far more compact than an introspection result for the same information.
7677

7778
## Mutations

‎src/EntityGraphQL/Schema/ISchemaProvider.cs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Security.Claims;
4+
using System.Threading;
5+
using System.Threading.Tasks;
36
using EntityGraphQL.Compiler.EntityQuery;
47
using EntityGraphQL.Directives;
58
using Microsoft.Extensions.Logging;
@@ -93,7 +96,17 @@ void AddMutationsFrom<TType>(SchemaBuilderOptions? options = null)
9396
void RemoveTypeAndAllFields<TSchemaType>();
9497
void RemoveTypeAndAllFields(string typeName);
9598
string ToGraphQLSchemaString(bool includeDescriptions = true);
96-
string ToGraphQLSchemaString(QueryRequestContext? requestContext, bool includeDescriptions = true);
99+
100+
/// <summary>SDL with only what the user in <paramref name="requestContext"/> may see. See SchemaProvider for details.</summary>
101+
// default implementations so adding these does not break anyone implementing ISchemaProvider themselves
102+
string ToGraphQLSchemaString(QueryRequestContext? requestContext, bool includeDescriptions = true) => SchemaGenerator.Make(this, includeDescriptions, requestContext);
103+
104+
/// <summary>SDL with only what <paramref name="user"/> may see, filtered by this schema's AuthorizationService. See SchemaProvider for details.</summary>
105+
async Task<string> ToGraphQLSchemaStringAsync(ClaimsPrincipal? user, bool includeDescriptions = true, CancellationToken cancellationToken = default)
106+
{
107+
var authorizationService = await AuthorizationService.PrepareForRequestAsync(this, user, cancellationToken);
108+
return SchemaGenerator.Make(this, includeDescriptions, new QueryRequestContext(authorizationService, user));
109+
}
97110
SchemaType<TType> Type<TType>();
98111
SchemaType<TType> Type<TType>(string typeName);
99112
ISchemaType Type(string typeName);

‎src/EntityGraphQL/Schema/SchemaGenerator.cs‎

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,22 @@ internal static string EscapeString(string? input)
2424
return input.Replace("\\", "\\\\").Replace("\"", "\\\"");
2525
}
2626

27-
/// <param name="requestContext">When supplied only types and fields this user may access are output, using the same
28-
/// rules as introspection (see <see cref="SchemaIntrospection.IsVisible(QueryRequestContext?, IField)"/>). Null outputs everything</param>
27+
/// <param name="requestContext">When supplied only the types and fields this user may access are output, using the same
28+
/// rules as introspection (see <see cref="SchemaVisibility"/>). Null outputs everything</param>
2929
internal static string Make(ISchemaProvider schema, bool includeDescriptions = true, QueryRequestContext? requestContext = null)
3030
{
31+
var visibility = SchemaVisibility.For(schema, requestContext);
3132
var rootQueryType = schema.GetSchemaType(schema.QueryContextType, false, null);
3233
var mutationType = schema.Mutation().SchemaType;
3334
var subscriptionType = schema.Subscription().SchemaType;
3435

35-
var types = BuildSchemaTypes(schema, includeDescriptions, requestContext);
36+
var types = BuildSchemaTypes(schema, includeDescriptions, visibility);
3637

3738
var schemaBuilder = new StringBuilder("schema {");
3839
schemaBuilder.AppendLine();
3940
schemaBuilder.AppendLine($"\tquery: {rootQueryType.Name}");
40-
bool outputMutation = VisibleFields(mutationType, requestContext).Any();
41-
bool outputSubscription = VisibleFields(subscriptionType, requestContext).Any();
41+
bool outputMutation = VisibleFields(mutationType, visibility).Any();
42+
bool outputSubscription = VisibleFields(subscriptionType, visibility).Any();
4243
if (outputMutation)
4344
schemaBuilder.AppendLine($"\tmutation: {mutationType.Name}");
4445
if (outputSubscription)
@@ -64,32 +65,32 @@ internal static string Make(ISchemaProvider schema, bool includeDescriptions = t
6465
}
6566
schemaBuilder.AppendLine();
6667

67-
schemaBuilder.Append(BuildEnumTypes(schema, includeDescriptions, requestContext));
68+
schemaBuilder.Append(BuildEnumTypes(schema, includeDescriptions, visibility));
6869

69-
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.GetSchemaType(schema.QueryContextName, null), includeDescriptions, requestContext));
70+
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.GetSchemaType(schema.QueryContextName, null), includeDescriptions, visibility));
7071

7172
schemaBuilder.Append(types);
7273

7374
if (outputMutation)
74-
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Mutation().SchemaType, includeDescriptions, requestContext));
75+
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Mutation().SchemaType, includeDescriptions, visibility));
7576
if (outputSubscription)
76-
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Subscription().SchemaType, includeDescriptions, requestContext));
77+
schemaBuilder.AppendLine(OutputSchemaType(schema, schema.Subscription().SchemaType, includeDescriptions, visibility));
7778

7879
return schemaBuilder.ToString();
7980
}
8081

81-
/// <summary>Fields of a type that are output: not internal (__) and, with a request context, visible to that user</summary>
82-
private static IEnumerable<IField> VisibleFields(ISchemaType schemaType, QueryRequestContext? requestContext) =>
83-
schemaType.GetFields().Where(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture) && SchemaIntrospection.IsVisible(requestContext, f));
82+
/// <summary>Fields of a type that are output: not internal (__) and visible to the user, if filtering</summary>
83+
private static IEnumerable<IField> VisibleFields(ISchemaType schemaType, SchemaVisibility visibility) =>
84+
schemaType.GetFields().Where(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture) && visibility.IsFieldVisible(f));
8485

85-
private static string BuildEnumTypes(ISchemaProvider schema, bool includeDescriptions, QueryRequestContext? requestContext)
86+
private static string BuildEnumTypes(ISchemaProvider schema, bool includeDescriptions, SchemaVisibility visibility)
8687
{
8788
var types = new StringBuilder();
8889
foreach (var typeItem in schema.GetNonContextTypes().OrderBy(t => t.Name))
8990
{
9091
if (typeItem.Name.StartsWith("__", StringComparison.InvariantCulture) || !typeItem.IsEnum)
9192
continue;
92-
if (!SchemaIntrospection.IsVisible(requestContext, typeItem.RequiredAuthorization))
93+
if (!visibility.IsTypeVisible(typeItem))
9394
continue;
9495

9596
if (includeDescriptions && !string.IsNullOrEmpty(typeItem.Description))
@@ -113,7 +114,7 @@ private static string BuildEnumTypes(ISchemaProvider schema, bool includeDescrip
113114
return types.ToString();
114115
}
115116

116-
private static string BuildSchemaTypes(ISchemaProvider schema, bool includeDescriptions, QueryRequestContext? requestContext)
117+
private static string BuildSchemaTypes(ISchemaProvider schema, bool includeDescriptions, SchemaVisibility visibility)
117118
{
118119
var types = new StringBuilder();
119120
foreach (var typeItem in schema.GetNonContextTypes().OrderBy(t => t.Name))
@@ -127,15 +128,15 @@ private static string BuildSchemaTypes(ISchemaProvider schema, bool includeDescr
127128
)
128129
continue;
129130

130-
if (!SchemaIntrospection.IsVisible(requestContext, typeItem.RequiredAuthorization))
131+
if (!visibility.IsTypeVisible(typeItem))
131132
continue;
132133

133134
if (!typeItem.GetFields().Any(f => !f.Name.StartsWith("__", StringComparison.InvariantCulture)) && typeItem.GqlType != GqlTypes.Union && typeItem.BaseTypesReadOnly.Count == 0)
134135
continue;
135136

136-
var output = OutputSchemaType(schema, typeItem, includeDescriptions, requestContext);
137+
var output = OutputSchemaType(schema, typeItem, includeDescriptions, visibility);
137138
// without a request context keep the historic output byte-for-byte (it appended even an empty union)
138-
if (output.Length > 0 || requestContext == null)
139+
if (output.Length > 0 || !visibility.IsFiltering)
139140
types.AppendLine(output);
140141
}
141142

@@ -260,20 +261,17 @@ private static string GetDirectiveArgs(ISchemaProvider schema, IDirectiveProcess
260261
return string.IsNullOrEmpty(allArgs) ? string.Empty : $"({allArgs})";
261262
}
262263

263-
private static string OutputSchemaType(ISchemaProvider schema, ISchemaType schemaType, bool includeDescriptions, QueryRequestContext? requestContext)
264+
private static string OutputSchemaType(ISchemaProvider schema, ISchemaType schemaType, bool includeDescriptions, SchemaVisibility visibility)
264265
{
265266
var sb = new StringBuilder();
266-
var fields = VisibleFields(schemaType, requestContext).OrderBy(s => s.Name).ToList();
267-
// every field hidden from this user - an empty type is not valid SDL, and the type is not reachable anyway
268-
if (requestContext != null && fields.Count == 0 && schemaType.GqlType != GqlTypes.Union)
269-
return string.Empty;
267+
var fields = VisibleFields(schemaType, visibility).OrderBy(s => s.Name).ToList();
270268

271269
if (includeDescriptions && !string.IsNullOrEmpty(schemaType.Description))
272270
sb.AppendLine($"\"\"\"{EscapeString(schemaType.Description)}\"\"\"");
273271

274272
if (schemaType.GqlType == GqlTypes.Union)
275273
{
276-
var possibleTypes = schemaType.PossibleTypesReadOnly.Where(i => SchemaIntrospection.IsVisible(requestContext, i.RequiredAuthorization)).ToList();
274+
var possibleTypes = schemaType.PossibleTypesReadOnly.Where(visibility.IsTypeVisible).ToList();
277275
if (possibleTypes.Count == 0)
278276
{
279277
return string.Empty;
@@ -292,9 +290,18 @@ private static string OutputSchemaType(ISchemaProvider schema, ISchemaType schem
292290
};
293291

294292
var implements = "";
295-
if (schemaType.BaseTypesReadOnly != null && schemaType.BaseTypesReadOnly.Count > 0)
293+
var baseTypes = schemaType.BaseTypesReadOnly?.Where(visibility.IsTypeVisible).ToList();
294+
if (baseTypes != null && baseTypes.Count > 0)
296295
{
297-
implements += $" implements {string.Join(" & ", schemaType.BaseTypesReadOnly.Select(i => i.Name))}";
296+
implements += $" implements {string.Join(" & ", baseTypes.Select(i => i.Name))}";
297+
}
298+
299+
// the query root can be left with no fields (SchemaVisibility hides any other type it empties). The SDL grammar
300+
// allows a type with no field block, so it stays a parseable root rather than disappearing from under `schema`
301+
if (visibility.IsFiltering && fields.Count == 0 && schemaType.Name == schema.QueryContextName)
302+
{
303+
sb.AppendLine($"{type} {schemaType.Name}{implements}{GetDirectives(schemaType.Directives)}");
304+
return sb.ToString();
298305
}
299306

300307
sb.AppendLine($"{type} {schemaType.Name}{implements}{GetDirectives(schemaType.Directives)} {{");

0 commit comments

Comments
 (0)