diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go index 7f115f1e83..a9948b9dcf 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go @@ -1649,6 +1649,17 @@ func TestGraphQLDataSourceFederation(t *testing.T) { ), Info: &resolve.GraphQLResponseInfo{ OperationType: ast.OperationTypeQuery, + // collected by the postprocess step, which this test runs with + // collection enabled (it is disabled in the default test post-processor) + AuthorizationCoordinates: []resolve.AuthorizationCoordinate{ + { + DataSourceID: "account.service", + Coordinate: resolve.GraphCoordinate{ + TypeName: "Account", + FieldName: "shippingInfo", + }, + }, + }, }, Data: &resolve.Object{ Fields: []*resolve.Field{ @@ -1761,7 +1772,15 @@ func TestGraphQLDataSourceFederation(t *testing.T) { }, }, }, - planConfiguration, WithFieldInfo(), WithDefaultPostProcessor())) + planConfiguration, + WithFieldInfo(), + // default post-processor options, but with authorization coordinate collection enabled + WithDefaultCustomPostProcessor( + postprocess.DisableResolveInputTemplates(), + postprocess.DisableCreateConcreteSingleFetchTypes(), + postprocess.DisableCreateParallelNodes(), + postprocess.DisableMergeFields(), + ))) }) t.Run("composite keys variant", func(t *testing.T) { diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index f351533616..08147d7d02 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -51,7 +51,13 @@ func WithSkipReason(reason string) func(*testOptions) { func WithDefaultPostProcessor() func(*testOptions) { return func(o *testOptions) { - o.postProcessor = postprocess.NewProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields()) + o.postProcessor = postprocess.NewProcessor( + postprocess.DisableResolveInputTemplates(), + postprocess.DisableCreateConcreteSingleFetchTypes(), + postprocess.DisableCreateParallelNodes(), + postprocess.DisableMergeFields(), + postprocess.DisableCollectAuthorizationCoordinates(), + ) } } diff --git a/v2/pkg/engine/postprocess/collect_authorization_coordinates.go b/v2/pkg/engine/postprocess/collect_authorization_coordinates.go new file mode 100644 index 0000000000..1c2fefdd96 --- /dev/null +++ b/v2/pkg/engine/postprocess/collect_authorization_coordinates.go @@ -0,0 +1,132 @@ +package postprocess + +import ( + "sort" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// collectAuthorizationCoordinates is a post-processing step that records, on the response's +// GraphQLResponseInfo, every field coordinate that carries an authorization rule +// (@requiresScopes / @authenticated) together with the data source that resolves it. It runs right +// after createFetchTree while the fetch tree is still flat — so the fetch side is a plain loop over +// the root's children (plus RawFetches, which still hold the fetches when extraction is disabled). +// The result is request-independent and cached with the plan; when pre-fetch field authorization is +// enabled the resolver asks the BatchAuthorizer to decide all of these coordinates up front, before +// any fetch executes. +// +// Coordinates are deduplicated by {DataSourceID, TypeName, FieldName} and sorted for determinism. +// When the operation selects no protected field the list is left empty, which makes the enabled mode +// a no-op. +type collectAuthorizationCoordinates struct { + disable bool +} + +type authorizationCoordinateKey struct { + dataSourceID string + typeName string + fieldName string +} + +func (c *collectAuthorizationCoordinates) Process(response *resolve.GraphQLResponse) { + if c.disable { + return + } + if response == nil || response.Info == nil { + return + } + + coordinates := make(map[authorizationCoordinateKey]resolve.AuthorizationCoordinate) + for i := range response.RawFetches { + c.collectFetchItem(response.RawFetches[i], coordinates) + } + if response.Fetches != nil { + c.collectFetchItem(response.Fetches.Item, coordinates) + for _, child := range response.Fetches.ChildNodes { + if child == nil { + continue + } + c.collectFetchItem(child.Item, coordinates) + } + } + c.collectNode(response.Data, coordinates) + if len(coordinates) == 0 { + response.Info.AuthorizationCoordinates = nil + return + } + + response.Info.AuthorizationCoordinates = response.Info.AuthorizationCoordinates[:0] + for _, coordinate := range coordinates { + response.Info.AuthorizationCoordinates = append(response.Info.AuthorizationCoordinates, coordinate) + } + sort.Slice(response.Info.AuthorizationCoordinates, func(i, j int) bool { + left := response.Info.AuthorizationCoordinates[i] + right := response.Info.AuthorizationCoordinates[j] + if left.DataSourceID != right.DataSourceID { + return left.DataSourceID < right.DataSourceID + } + if left.Coordinate.TypeName != right.Coordinate.TypeName { + return left.Coordinate.TypeName < right.Coordinate.TypeName + } + return left.Coordinate.FieldName < right.Coordinate.FieldName + }) +} + +func (c *collectAuthorizationCoordinates) collectFetchItem(item *resolve.FetchItem, coordinates map[authorizationCoordinateKey]resolve.AuthorizationCoordinate) { + if item == nil || item.Fetch == nil { + return + } + info := item.Fetch.FetchInfo() + if info == nil { + return + } + for i := range info.RootFields { + if !info.RootFields[i].HasAuthorizationRule { + continue + } + c.addCoordinate(coordinates, info.DataSourceID, info.RootFields[i]) + } +} + +func (c *collectAuthorizationCoordinates) collectNode(node resolve.Node, coordinates map[authorizationCoordinateKey]resolve.AuthorizationCoordinate) { + switch n := node.(type) { + case *resolve.Object: + if n == nil { + return + } + for i := range n.Fields { + field := n.Fields[i] + if field.Info != nil && field.Info.HasAuthorizationRule { + // A merged (e.g. @shareable) field can be resolved by multiple data sources; seed a + // coordinate for each so every source that could serve it gets a pre-fetch decision. + for _, dataSourceID := range field.Info.Source.IDs { + c.addCoordinate(coordinates, dataSourceID, resolve.GraphCoordinate{ + TypeName: field.Info.ExactParentTypeName, + FieldName: field.Info.Name, + }) + } + } + c.collectNode(field.Value, coordinates) + } + case *resolve.Array: + if n == nil { + return + } + c.collectNode(n.Item, coordinates) + } +} + +func (c *collectAuthorizationCoordinates) addCoordinate(coordinates map[authorizationCoordinateKey]resolve.AuthorizationCoordinate, dataSourceID string, coordinate resolve.GraphCoordinate) { + key := authorizationCoordinateKey{ + dataSourceID: dataSourceID, + typeName: coordinate.TypeName, + fieldName: coordinate.FieldName, + } + coordinates[key] = resolve.AuthorizationCoordinate{ + DataSourceID: dataSourceID, + Coordinate: resolve.GraphCoordinate{ + TypeName: coordinate.TypeName, + FieldName: coordinate.FieldName, + }, + } +} diff --git a/v2/pkg/engine/postprocess/collect_authorization_coordinates_test.go b/v2/pkg/engine/postprocess/collect_authorization_coordinates_test.go new file mode 100644 index 0000000000..3f368dfcf1 --- /dev/null +++ b/v2/pkg/engine/postprocess/collect_authorization_coordinates_test.go @@ -0,0 +1,261 @@ +package postprocess + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// protectedProductsData is a data tree with one protected nested field: Product.secret, resolved by +// the "products" data source, under Query.products. +func protectedProductsData() *resolve.Object { + return &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("products"), + Info: &resolve.FieldInfo{ + Name: "products", + ExactParentTypeName: "Query", + Source: resolve.TypeFieldSource{IDs: []string{"products"}, Names: []string{"products"}}, + }, + Value: &resolve.Array{ + Path: []string{"products"}, + Nullable: true, + Item: &resolve.Object{ + Nullable: true, + TypeName: "Product", + Fields: []*resolve.Field{ + { + Name: []byte("secret"), + Info: &resolve.FieldInfo{ + Name: "secret", + ExactParentTypeName: "Product", + Source: resolve.TypeFieldSource{IDs: []string{"products"}, Names: []string{"products"}}, + HasAuthorizationRule: true, + }, + Value: &resolve.String{Path: []string{"secret"}, Nullable: true}, + }, + }, + }, + }, + }, + }, + } +} + +func TestCollectAuthorizationCoordinates_FlatFetchTreeAndDataTree(t *testing.T) { + response := &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: resolve.Sequence( + resolve.SingleWithPath(&resolve.SingleFetch{ + Info: &resolve.FetchInfo{ + DataSourceID: "catalog", + RootFields: []resolve.GraphCoordinate{ + {TypeName: "Query", FieldName: "products", HasAuthorizationRule: true}, + {TypeName: "Query", FieldName: "public"}, + }, + }, + }, "query"), + ), + Data: protectedProductsData(), + } + + (&collectAuthorizationCoordinates{}).Process(response) + + assert.Equal(t, []resolve.AuthorizationCoordinate{ + {DataSourceID: "catalog", Coordinate: resolve.GraphCoordinate{TypeName: "Query", FieldName: "products"}}, + {DataSourceID: "products", Coordinate: resolve.GraphCoordinate{TypeName: "Product", FieldName: "secret"}}, + }, response.Info.AuthorizationCoordinates) +} + +// When fetch extraction has not run (or is disabled), the fetches still live in RawFetches; the +// collector covers that container too. +func TestCollectAuthorizationCoordinates_RawFetches(t *testing.T) { + response := &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + RawFetches: []*resolve.FetchItem{ + {Fetch: &resolve.SingleFetch{ + Info: &resolve.FetchInfo{ + DataSourceID: "catalog", + RootFields: []resolve.GraphCoordinate{ + {TypeName: "Query", FieldName: "products", HasAuthorizationRule: true}, + {TypeName: "Query", FieldName: "public"}, + }, + }, + }}, + }, + } + + (&collectAuthorizationCoordinates{}).Process(response) + + assert.Equal(t, []resolve.AuthorizationCoordinate{ + {DataSourceID: "catalog", Coordinate: resolve.GraphCoordinate{TypeName: "Query", FieldName: "products"}}, + }, response.Info.AuthorizationCoordinates) +} + +func TestCollectAuthorizationCoordinates_DeduplicatesFetchAndDataTree(t *testing.T) { + // The same coordinate reachable via a fetch root field and via the data tree yields one entry; + // a @shareable field with several data sources yields one entry per source. + response := &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + RawFetches: []*resolve.FetchItem{ + {Fetch: &resolve.SingleFetch{ + Info: &resolve.FetchInfo{ + DataSourceID: "users", + RootFields: []resolve.GraphCoordinate{ + {TypeName: "Query", FieldName: "me", HasAuthorizationRule: true}, + }, + }, + }}, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Info: &resolve.FieldInfo{ + Name: "me", + ExactParentTypeName: "Query", + Source: resolve.TypeFieldSource{IDs: []string{"accounts", "users"}, Names: []string{"accounts", "users"}}, + HasAuthorizationRule: true, + }, + Value: &resolve.String{Path: []string{"me"}}, + }, + }, + }, + } + + (&collectAuthorizationCoordinates{}).Process(response) + + assert.Equal(t, []resolve.AuthorizationCoordinate{ + {DataSourceID: "accounts", Coordinate: resolve.GraphCoordinate{TypeName: "Query", FieldName: "me"}}, + {DataSourceID: "users", Coordinate: resolve.GraphCoordinate{TypeName: "Query", FieldName: "me"}}, + }, response.Info.AuthorizationCoordinates) +} + +func TestCollectAuthorizationCoordinates_NoProtectedFields(t *testing.T) { + response := &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + RawFetches: []*resolve.FetchItem{ + {Fetch: &resolve.SingleFetch{ + Info: &resolve.FetchInfo{ + DataSourceID: "catalog", + RootFields: []resolve.GraphCoordinate{{TypeName: "Query", FieldName: "public"}}, + }, + }}, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("public"), + Info: &resolve.FieldInfo{ + Name: "public", + ExactParentTypeName: "Query", + Source: resolve.TypeFieldSource{IDs: []string{"catalog"}, Names: []string{"catalog"}}, + }, + Value: &resolve.String{Path: []string{"public"}}, + }, + }, + }, + } + + (&collectAuthorizationCoordinates{}).Process(response) + + assert.Nil(t, response.Info.AuthorizationCoordinates) +} + +// TestProcess_CollectsAuthorizationCoordinates verifies the wiring: Process populates the +// coordinates for every plan kind, from both the raw fetches and the data tree. +func TestProcess_CollectsAuthorizationCoordinates(t *testing.T) { + processor := NewProcessor( + DisableDeduplicateSingleFetches(), + DisableCreateConcreteSingleFetchTypes(), + DisableMergeFields(), + DisableCreateParallelNodes(), + DisableAddMissingNestedDependencies(), + DisableResolveInputTemplates(), + DisableExtractDeferFetches(), + DisableBuildDeferTree(), + ) + + rawFetches := func() []*resolve.FetchItem { + return []*resolve.FetchItem{ + {Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{FetchID: 1}, + Info: &resolve.FetchInfo{ + DataSourceID: "catalog", + RootFields: []resolve.GraphCoordinate{ + {TypeName: "Query", FieldName: "products", HasAuthorizationRule: true}, + }, + }, + }}, + } + } + expected := []resolve.AuthorizationCoordinate{ + {DataSourceID: "catalog", Coordinate: resolve.GraphCoordinate{TypeName: "Query", FieldName: "products"}}, + {DataSourceID: "products", Coordinate: resolve.GraphCoordinate{TypeName: "Product", FieldName: "secret"}}, + } + + t.Run("synchronous response plan", func(t *testing.T) { + p := &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + RawFetches: rawFetches(), + Data: protectedProductsData(), + }, + } + + processor.Process(p) + + assert.Equal(t, expected, p.Response.Info.AuthorizationCoordinates) + }) + + t.Run("defer response plan", func(t *testing.T) { + p := &plan.DeferResponsePlan{ + Response: &resolve.GraphQLDeferResponse{ + Response: &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + RawFetches: rawFetches(), + Data: protectedProductsData(), + }, + }, + } + + processor.Process(p) + + assert.Equal(t, expected, p.Response.Response.Info.AuthorizationCoordinates) + }) + + t.Run("subscription response plan", func(t *testing.T) { + p := &plan.SubscriptionResponsePlan{ + Response: &resolve.GraphQLSubscription{ + Response: &resolve.GraphQLResponse{ + Info: &resolve.GraphQLResponseInfo{OperationType: ast.OperationTypeSubscription}, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("events"), + Info: &resolve.FieldInfo{ + Name: "events", + ExactParentTypeName: "Subscription", + Source: resolve.TypeFieldSource{IDs: []string{"events"}, Names: []string{"events"}}, + HasAuthorizationRule: true, + }, + Value: &resolve.String{Path: []string{"events"}}, + }, + }, + }, + }, + }, + } + + processor.Process(p) + + assert.Equal(t, []resolve.AuthorizationCoordinate{ + {DataSourceID: "events", Coordinate: resolve.GraphCoordinate{TypeName: "Subscription", FieldName: "events"}}, + }, p.Response.Response.Info.AuthorizationCoordinates) + }) +} diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index d57fb27232..71f47d561b 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -28,17 +28,20 @@ type Processor struct { } type FetchTreeProcessors struct { - resolveInputTemplates *resolveInputTemplates - appendFetchID *fetchIDAppender - dedupe *deduplicateSingleFetches - addMissingNestedDependencies *addMissingNestedDependencies - createConcreteSingleFetchTypes *createConcreteSingleFetchTypes - orderSequenceByDependencies *orderSequenceByDependencies - createParallelNodes *createParallelNodes + collectAuthorizationCoordinates *collectAuthorizationCoordinates + resolveInputTemplates *resolveInputTemplates + appendFetchID *fetchIDAppender + dedupe *deduplicateSingleFetches + addMissingNestedDependencies *addMissingNestedDependencies + createConcreteSingleFetchTypes *createConcreteSingleFetchTypes + orderSequenceByDependencies *orderSequenceByDependencies + createParallelNodes *createParallelNodes } -// processFlatFetchTree - process a flat fetch tree - single serial fetch with flat list of child fetches -func (p *FetchTreeProcessors) processFlatFetchTree(fetches *resolve.FetchTreeNode) { +// processFlatFetchTree - process a flat fetch tree - single serial fetch with a flat list of child fetches +func (p *FetchTreeProcessors) processFlatFetchTree(response *resolve.GraphQLResponse) { + p.collectAuthorizationCoordinates.Process(response) + fetches := response.Fetches p.dedupe.ProcessFetchTree(fetches) // Appending fetchIDs makes query content unique, thus it should happen after "dedupe". p.appendFetchID.ProcessFetchTree(fetches) @@ -59,18 +62,19 @@ type ResponseTreeProcessors struct { } type processorOptions struct { - disableDeduplicateSingleFetches bool - disableCreateConcreteSingleFetchTypes bool - disableOrderSequenceByDependencies bool - disableMergeFields bool - disableRewriteOpNames bool - disableResolveInputTemplates bool - disableExtractFetches bool - disableCreateParallelNodes bool - disableAddMissingNestedDependencies bool - collectDataSourceInfo bool - disableExtractDeferFetches bool - disableBuildDeferTree bool + disableDeduplicateSingleFetches bool + disableCreateConcreteSingleFetchTypes bool + disableOrderSequenceByDependencies bool + disableMergeFields bool + disableRewriteOpNames bool + disableResolveInputTemplates bool + disableExtractFetches bool + disableCreateParallelNodes bool + disableAddMissingNestedDependencies bool + collectDataSourceInfo bool + disableExtractDeferFetches bool + disableBuildDeferTree bool + disableCollectAuthorizationCoordinates bool } type ProcessorOption func(*processorOptions) @@ -136,6 +140,12 @@ func DisableBuildDeferTree() ProcessorOption { } } +func DisableCollectAuthorizationCoordinates() ProcessorOption { + return func(o *processorOptions) { + o.disableCollectAuthorizationCoordinates = true + } +} + func NewProcessor(options ...ProcessorOption) *Processor { opts := &processorOptions{} for _, o := range options { @@ -145,6 +155,9 @@ func NewProcessor(options ...ProcessorOption) *Processor { collectDataSourceInfo: opts.collectDataSourceInfo, disableExtractFetches: opts.disableExtractFetches, fetchTreeProcessors: &FetchTreeProcessors{ + collectAuthorizationCoordinates: &collectAuthorizationCoordinates{ + disable: opts.disableCollectAuthorizationCoordinates, + }, resolveInputTemplates: &resolveInputTemplates{ disable: opts.disableResolveInputTemplates, }, @@ -193,13 +206,13 @@ func (p *Processor) Process(pre plan.Plan) { p.responseTreeProcessors.mergeFields.Process(t.Response.Data) // initialize the fetch tree p.createFetchTree(t.Response) - p.fetchTreeProcessors.processFlatFetchTree(t.Response.Fetches) + p.fetchTreeProcessors.processFlatFetchTree(t.Response) p.fetchTreeProcessors.organizeFetchTree(t.Response.Fetches) case *plan.DeferResponsePlan: p.responseTreeProcessors.mergeFields.Process(t.Response.Response.Data) p.createFetchTree(t.Response.Response) - p.fetchTreeProcessors.processFlatFetchTree(t.Response.Response.Fetches) + p.fetchTreeProcessors.processFlatFetchTree(t.Response.Response) // extract deferred fetches into their own fetch trees p.extractDeferFetches.Process(t) @@ -222,7 +235,7 @@ func (p *Processor) Process(pre plan.Plan) { p.createFetchTree(t.Response.Response) p.appendTriggerToFetchTree(t.Response) - p.fetchTreeProcessors.processFlatFetchTree(t.Response.Response.Fetches) + p.fetchTreeProcessors.processFlatFetchTree(t.Response.Response) // resolve input template for the root query in the subscription trigger p.fetchTreeProcessors.resolveInputTemplates.ProcessTrigger(&t.Response.Trigger) diff --git a/v2/pkg/engine/resolve/authorization_prefetch_test.go b/v2/pkg/engine/resolve/authorization_prefetch_test.go new file mode 100644 index 0000000000..b666bae230 --- /dev/null +++ b/v2/pkg/engine/resolve/authorization_prefetch_test.go @@ -0,0 +1,805 @@ +package resolve + +import ( + "bytes" + "context" + "encoding/json" + "io" + "sync/atomic" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" +) + +type batchTestAuthorizer struct { + preFetchCalls atomic.Int64 + objectFieldCalls atomic.Int64 + batchCalls atomic.Int64 + + decisions map[GraphCoordinate]AuthorizationDecision + seen [][]GraphCoordinate +} + +func (a *batchTestAuthorizer) AuthorizePreFetch(ctx *Context, dataSourceID string, input json.RawMessage, coordinate GraphCoordinate) (result *AuthorizationDeny, err error) { + a.preFetchCalls.Add(1) + return nil, nil +} + +func (a *batchTestAuthorizer) AuthorizeObjectField(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (result *AuthorizationDeny, err error) { + a.objectFieldCalls.Add(1) + return nil, nil +} + +func (a *batchTestAuthorizer) HasResponseExtensionData(ctx *Context) bool { + return false +} + +func (a *batchTestAuthorizer) RenderResponseExtension(ctx *Context, out io.Writer) error { + return nil +} + +func (a *batchTestAuthorizer) AuthorizeFields(ctx *Context, coordinates []GraphCoordinate) (decisions []AuthorizationDecision, err error) { + a.batchCalls.Add(1) + a.seen = append(a.seen, append([]GraphCoordinate(nil), coordinates...)) + decisions = make([]AuthorizationDecision, len(coordinates)) + for i := range coordinates { + decision, ok := a.decisions[GraphCoordinate{ + TypeName: coordinates[i].TypeName, + FieldName: coordinates[i].FieldName, + }] + if ok { + decisions[i] = decision + continue + } + decisions[i] = AuthorizationDecision{Allowed: true} + } + return decisions, nil +} + +func TestPreFetchFieldAuthorization(t *testing.T) { + t.Run("enabled denied query root skips dedicated fetch", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT().Load(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + response := singleFieldResponse(service, "account", &String{ + Path: []string{"account"}, + Nullable: true, + }, GraphCoordinate{ + TypeName: "Query", + FieldName: "account", + HasAuthorizationRule: true, + }) + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "accounts", Coordinate: GraphCoordinate{TypeName: "Query", FieldName: "account"}}, + } + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Query", FieldName: "account"}: {Allowed: false, Reason: "missing scope 'account:read'"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.account', Reason: missing scope 'account:read'.","path":["account"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"account":null}}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + + t.Run("enabled no protected coordinates skips batch", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]byte(`{"data":{"name":"Ada"}}`), nil). + Times(1) + + response := singleFieldResponse(service, "name", &String{ + Path: []string{"name"}, + }, GraphCoordinate{ + TypeName: "Query", + FieldName: "name", + }) + authorizer := &batchTestAuthorizer{} + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"data":{"name":"Ada"}}`, buf.String()) + assert.Equal(t, int64(0), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + + t.Run("enabled denied query root sharing fetch keeps authorized sibling", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]byte(`{"data":{"public":"visible","secret":"hidden"}}`), nil). + Times(1) + + response := sharedRootFieldResponse(service) + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "accounts", Coordinate: GraphCoordinate{TypeName: "Query", FieldName: "secret"}}, + } + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Query", FieldName: "secret"}: {Allowed: false, Reason: "missing scope 'secret:read'"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.secret', Reason: missing scope 'secret:read'.","path":["secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"public":"visible","secret":null}}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + + t.Run("enabled nested protected field under empty list emits wildcard path", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]byte(`{"data":{"products":[]}}`), nil). + Times(1) + + response := productsSecretResponse(service) + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "products", Coordinate: GraphCoordinate{TypeName: "Product", FieldName: "secret"}}, + } + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Product", FieldName: "secret"}: {Allowed: false, Reason: "missing scope 'read:secret'"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.products.@.secret', Reason: missing scope 'read:secret'.","path":["products","@","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"products":[]}}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + + t.Run("enabled batch called once across fetch and array fan out", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + response := generateTestFederationGraphQLResponse(t, ctrl) + // as collected by the postprocess package from the fixture's fetch infos and data tree + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "products", Coordinate: GraphCoordinate{TypeName: "Product", FieldName: "name"}}, + {DataSourceID: "reviews", Coordinate: GraphCoordinate{TypeName: "Review", FieldName: "body"}}, + {DataSourceID: "reviews", Coordinate: GraphCoordinate{TypeName: "Review", FieldName: "product"}}, + {DataSourceID: "reviews", Coordinate: GraphCoordinate{TypeName: "User", FieldName: "reviews"}}, + {DataSourceID: "users", Coordinate: GraphCoordinate{TypeName: "Query", FieldName: "me"}}, + } + + authorizer := &batchTestAuthorizer{} + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me","reviews":[{"body":"A highly effective form of birth control.","product":{"upc":"top-1","name":"Trilby"}},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","product":{"upc":"top-2","name":"Fedora"}}]}}}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + assert.Equal(t, [][]GraphCoordinate{{ + {TypeName: "Product", FieldName: "name"}, + {TypeName: "Review", FieldName: "body"}, + {TypeName: "Review", FieldName: "product"}, + {TypeName: "User", FieldName: "reviews"}, + {TypeName: "Query", FieldName: "me"}, + }}, authorizer.seen) + }) + + t.Run("enabled denied interface field uses static parent coordinate with runtime typename", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]byte(`{"data":{"profile":{"__typename":"User","secret":"hidden"}}}`), nil). + Times(1) + + response := interfaceSecretResponse(service) + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "profiles", Coordinate: GraphCoordinate{TypeName: "Profile", FieldName: "secret"}}, + } + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Profile", FieldName: "secret"}: {Allowed: false, Reason: "missing scope 'profile:secret'"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.profile.secret', Reason: missing scope 'profile:secret'.","path":["profile","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"profile":{"secret":null}}}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + assert.Equal(t, [][]GraphCoordinate{{ + {TypeName: "Profile", FieldName: "secret"}, + }}, authorizer.seen) + }) + + t.Run("enabled denied non-null mutation root emits a single field error and nulls data", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT().Load(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + response := singleFieldResponse(service, "updateAccount", &String{ + Path: []string{"updateAccount"}, + Nullable: false, + }, GraphCoordinate{ + TypeName: "Mutation", + FieldName: "updateAccount", + HasAuthorizationRule: true, + }) + response.Info.OperationType = ast.OperationTypeMutation + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "accounts", Coordinate: GraphCoordinate{TypeName: "Mutation", FieldName: "updateAccount"}}, + } + response.Fetches.Item.Fetch.FetchInfo().OperationType = ast.OperationTypeMutation + response.Data.Fields[0].Info.ExactParentTypeName = "Mutation" + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Mutation", FieldName: "updateAccount"}: {Allowed: false, Reason: "missing scope 'account:write'"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + // A denied mutation skips the origin request and reports exactly one field-level error, + // in the same shape as the query field errors — no extra subgraph-level error. + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Mutation.updateAccount', Reason: missing scope 'account:write'.","path":["updateAccount"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":null}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + + t.Run("enabled denied nullable mutation root emits a single field error and nulls only the field", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT().Load(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + // A nullable mutation root field: per the GraphQL spec, a field error on a nullable field + // nulls only that field (no data-root propagation). Nullability is respected identically to + // query fields; mutations are not special-cased. This variant also covers the no-reason + // error message format. + response := singleFieldResponse(service, "updateAccount", &String{ + Path: []string{"updateAccount"}, + Nullable: true, + }, GraphCoordinate{ + TypeName: "Mutation", + FieldName: "updateAccount", + HasAuthorizationRule: true, + }) + response.Info.OperationType = ast.OperationTypeMutation + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "accounts", Coordinate: GraphCoordinate{TypeName: "Mutation", FieldName: "updateAccount"}}, + } + response.Fetches.Item.Fetch.FetchInfo().OperationType = ast.OperationTypeMutation + response.Data.Fields[0].Info.ExactParentTypeName = "Mutation" + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Mutation", FieldName: "updateAccount"}: {Allowed: false}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Mutation.updateAccount'.","path":["updateAccount"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"updateAccount":null}}`, buf.String()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.preFetchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + +} + +// When the loader is skipped (e.g. query-plan-only responses) no origin fetch runs, so the batch +// authorizer must not be invoked. +func TestPreFetchFieldAuthorizationSkipLoader(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT().Load(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + response := singleFieldResponse(service, "account", &String{ + Path: []string{"account"}, + Nullable: true, + }, GraphCoordinate{ + TypeName: "Query", + FieldName: "account", + HasAuthorizationRule: true, + }) + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "accounts", Coordinate: GraphCoordinate{TypeName: "Query", FieldName: "account"}}, + } + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Query", FieldName: "account"}: {Allowed: false, Reason: "missing scope 'account:read'"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + resolveCtx.ExecutionOptions.SkipLoader = true + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"data":null}`, buf.String()) + assert.Equal(t, int64(0), authorizer.batchCalls.Load()) +} + +// A subscription's protected root field must be authorized before the trigger starts, so an +// unauthorized subscription never opens an upstream subscription. +func TestAuthorizeSubscriptionPreFetch(t *testing.T) { + newSubResponse := func() *GraphQLResponse { + return &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeSubscription}, + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("messageAdded"), + Info: &FieldInfo{ + Name: "messageAdded", + ExactParentTypeName: "Subscription", + Source: TypeFieldSource{IDs: []string{"chat"}, Names: []string{"chat"}}, + HasAuthorizationRule: true, + }, + Value: &String{Path: []string{"messageAdded"}, Nullable: true}, + }, + }, + }, + } + } + + t.Run("denied root field returns error body", func(t *testing.T) { + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Subscription", FieldName: "messageAdded"}: {Allowed: false, Reason: "missing scope 'chat:read'"}, + }, + } + ctx := NewContext(context.Background()) + ctx.SetPreFetchFieldAuthorizer(authorizer) + resolver := newResolver(context.Background()) + + body, denied, err := resolver.authorizeSubscriptionPreFetch(ctx, newSubResponse()) + require.NoError(t, err) + assert.True(t, denied) + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Subscription.messageAdded', Reason: missing scope 'chat:read'.","extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":null}`, string(body)) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + }) + + t.Run("allowed root field proceeds", func(t *testing.T) { + authorizer := &batchTestAuthorizer{} + ctx := NewContext(context.Background()) + ctx.SetPreFetchFieldAuthorizer(authorizer) + resolver := newResolver(context.Background()) + + body, denied, err := resolver.authorizeSubscriptionPreFetch(ctx, newSubResponse()) + require.NoError(t, err) + assert.False(t, denied) + assert.Nil(t, body) + }) + + t.Run("mode disabled is a no-op", func(t *testing.T) { + ctx := NewContext(context.Background()) + resolver := newResolver(context.Background()) + + body, denied, err := resolver.authorizeSubscriptionPreFetch(ctx, newSubResponse()) + require.NoError(t, err) + assert.False(t, denied) + assert.Nil(t, body) + }) + + t.Run("wrong decision count fails closed", func(t *testing.T) { + ctx := NewContext(context.Background()) + ctx.SetPreFetchFieldAuthorizer(miscountBatchAuthorizer{}) + resolver := newResolver(context.Background()) + + body, denied, err := resolver.authorizeSubscriptionPreFetch(ctx, newSubResponse()) + require.Error(t, err) + assert.False(t, denied) + assert.Nil(t, body) + }) +} + +// miscountBatchAuthorizer returns the wrong number of decisions to exercise the fail-closed path. +type miscountBatchAuthorizer struct{} + +func (miscountBatchAuthorizer) AuthorizeFields(_ *Context, _ []GraphCoordinate) ([]AuthorizationDecision, error) { + return nil, nil +} + +// A denied non-null protected child under a nullable parent that the origin DID return must produce +// exactly one error: the walk emits it and nulls the parent, and the unreached-data sweep must not +// re-emit the same error for the just-nulled parent. +func TestPreFetchFieldAuthorizationNoDuplicateErrorOnNullPropagation(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewMockDataSource(ctrl) + service.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]byte(`{"data":{"user":{"secret":"hidden"}}}`), nil). + Times(1) + + response := &GraphQLResponse{ + Info: &GraphQLResponseInfo{OperationType: ast.OperationTypeQuery}, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: service, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + InputTemplate: InputTemplate{Segments: []TemplateSegment{{SegmentType: StaticSegmentType, Data: []byte(`{}`)}}}, + Info: &FetchInfo{ + DataSourceID: "users", + DataSourceName: "users", + RootFields: []GraphCoordinate{{TypeName: "Query", FieldName: "user"}}, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("user"), + Info: &FieldInfo{Name: "user", ExactParentTypeName: "Query", Source: TypeFieldSource{IDs: []string{"users"}, Names: []string{"users"}}}, + Value: &Object{ + Path: []string{"user"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{Name: "secret", ExactParentTypeName: "User", Source: TypeFieldSource{IDs: []string{"users"}, Names: []string{"users"}}, HasAuthorizationRule: true}, + Value: &String{Path: []string{"secret"}, Nullable: false}, + }, + }, + }, + }, + }, + }, + } + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "users", Coordinate: GraphCoordinate{TypeName: "User", FieldName: "secret"}}, + } + + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "User", FieldName: "secret"}: {Allowed: false, Reason: "no"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.user.secret', Reason: no.","path":["user","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"user":null}}`, buf.String()) +} + +func singleFieldResponse(service DataSource, fieldName string, value Node, rootField GraphCoordinate) *GraphQLResponse { + return &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: service, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {SegmentType: StaticSegmentType, Data: []byte(`{}`)}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "accounts", + DataSourceName: "accounts", + RootFields: []GraphCoordinate{rootField}, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte(fieldName), + Info: &FieldInfo{ + Name: fieldName, + ExactParentTypeName: "Query", + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"accounts"}, + }, + HasAuthorizationRule: rootField.HasAuthorizationRule, + }, + Value: value, + }, + }, + }, + } +} + +func sharedRootFieldResponse(service DataSource) *GraphQLResponse { + return &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: service, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {SegmentType: StaticSegmentType, Data: []byte(`{}`)}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "accounts", + DataSourceName: "accounts", + RootFields: []GraphCoordinate{ + {TypeName: "Query", FieldName: "public"}, + {TypeName: "Query", FieldName: "secret", HasAuthorizationRule: true}, + }, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("public"), + Info: &FieldInfo{ + Name: "public", + ExactParentTypeName: "Query", + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"accounts"}, + }, + }, + Value: &String{Path: []string{"public"}}, + }, + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Query", + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"accounts"}, + }, + HasAuthorizationRule: true, + }, + Value: &String{ + Path: []string{"secret"}, + Nullable: true, + }, + }, + }, + }, + } +} + +func productsSecretResponse(service DataSource) *GraphQLResponse { + return &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: service, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {SegmentType: StaticSegmentType, Data: []byte(`{}`)}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "products", + DataSourceName: "products", + RootFields: []GraphCoordinate{ + {TypeName: "Query", FieldName: "products"}, + }, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("products"), + Info: &FieldInfo{ + Name: "products", + ExactParentTypeName: "Query", + Source: TypeFieldSource{ + IDs: []string{"products"}, + Names: []string{"products"}, + }, + }, + Value: &Array{ + Path: []string{"products"}, + Nullable: true, + Item: &Object{ + Nullable: true, + TypeName: "Product", + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Product", + Source: TypeFieldSource{ + IDs: []string{"products"}, + Names: []string{"products"}, + }, + HasAuthorizationRule: true, + }, + Value: &String{ + Path: []string{"secret"}, + Nullable: true, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func interfaceSecretResponse(service DataSource) *GraphQLResponse { + return &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: service, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {SegmentType: StaticSegmentType, Data: []byte(`{}`)}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "profiles", + DataSourceName: "profiles", + RootFields: []GraphCoordinate{ + {TypeName: "Query", FieldName: "profile"}, + }, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("profile"), + Info: &FieldInfo{ + Name: "profile", + ExactParentTypeName: "Query", + Source: TypeFieldSource{ + IDs: []string{"profiles"}, + Names: []string{"profiles"}, + }, + }, + Value: &Object{ + Path: []string{"profile"}, + Nullable: true, + TypeName: "Profile", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Profile", + ParentTypeNames: []string{"Profile", "User"}, + Source: TypeFieldSource{ + IDs: []string{"profiles"}, + Names: []string{"profiles"}, + }, + HasAuthorizationRule: true, + }, + Value: &String{ + Path: []string{"secret"}, + Nullable: true, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestPreFetchFieldAuthorizationContextFreeResetsAuthorizer(t *testing.T) { + ctx := NewContext(context.Background()) + ctx.SetPreFetchFieldAuthorizer(&batchTestAuthorizer{}) + ctx.Free() + + assert.Nil(t, ctx.preFetchFieldAuthorizer) +} diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 76a5d1ca99..5e61644ca0 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -39,9 +39,15 @@ type Context struct { Extensions []byte LoaderHooks LoaderHooks - authorizer Authorizer - rateLimiter RateLimiter - fieldRenderer FieldValueRenderer + authorizer Authorizer + // preFetchFieldAuthorizer, when non-nil, enables pre-fetch field authorization: fields protected by + // an authorization rule are authorized in a single batch call before any subgraph fetch executes + // (scope-only, independent of the returned data), instead of being filtered out of the response after + // the fetch. Leaving it nil keeps the default post-fetch authorization behavior. It is distinct from + // authorizer, which performs post-fetch field filtering and renders the authorization response extension. + preFetchFieldAuthorizer BatchAuthorizer + rateLimiter RateLimiter + fieldRenderer FieldValueRenderer subgraphErrors map[string]error @@ -186,10 +192,28 @@ type Authorizer interface { RenderResponseExtension(ctx *Context, out io.Writer) error } +// AuthorizationDecision is an explicit allow/deny decision for a single field coordinate. +type AuthorizationDecision struct { + Allowed bool + Reason string +} + +// BatchAuthorizer authorizes field coordinates in one call before execution. +type BatchAuthorizer interface { + AuthorizeFields(ctx *Context, coordinates []GraphCoordinate) (decisions []AuthorizationDecision, err error) +} + func (c *Context) SetAuthorizer(authorizer Authorizer) { c.authorizer = authorizer } +// SetPreFetchFieldAuthorizer enables pre-fetch field authorization by supplying the batch authorizer +// used to resolve every protected field coordinate in one call before execution. Passing a non-nil +// authorizer turns the mode on; leaving it unset preserves the default post-fetch authorization behavior. +func (c *Context) SetPreFetchFieldAuthorizer(authorizer BatchAuthorizer) { + c.preFetchFieldAuthorizer = authorizer +} + func (c *Context) SetEngineLoaderHooks(hooks LoaderHooks) { c.LoaderHooks = hooks } @@ -315,6 +339,7 @@ func (c *Context) Free() { c.Extensions = nil c.subgraphErrors = nil c.authorizer = nil + c.preFetchFieldAuthorizer = nil c.LoaderHooks = nil c.GetDeduplicationData = nil c.SetDeduplicationData = nil diff --git a/v2/pkg/engine/resolve/field_authorization.go b/v2/pkg/engine/resolve/field_authorization.go new file mode 100644 index 0000000000..413ae49030 --- /dev/null +++ b/v2/pkg/engine/resolve/field_authorization.go @@ -0,0 +1,135 @@ +package resolve + +import ( + "fmt" + + "github.com/cespare/xxhash/v2" + + "github.com/wundergraph/astjson" +) + +// FieldAuthorization owns the per-request field-authorization decisions: it produces them +// (up-front batch in pre-fetch mode, memoized AuthorizeObjectField calls in the default mode) +// and answers lookups from the loader (fetch pruning) and the resolvable (field nulling). +// It is request-scoped: created next to the Resolvable in the resolver entry points, holding +// the request Context; it must never be stored on the Resolver. +type FieldAuthorization struct { + ctx *Context + + // allow caches allowed authorization decision ids (keyed by authorizationDecisionID over + // data source id + graph coordinate). + allow map[uint64]struct{} + + // deny caches denied authorization decision ids mapped to their deny reason. + deny map[uint64]string + + // marshalBuf is a scratch buffer for marshaling the object value passed to + // AuthorizeObjectField (legacy mode). Owned here, not shared with Resolvable's render buffer. + marshalBuf []byte +} + +func NewFieldAuthorization(ctx *Context) *FieldAuthorization { + return &FieldAuthorization{ + ctx: ctx, + allow: make(map[uint64]struct{}), + deny: make(map[uint64]string), + } +} + +// preFetchEnabled reports whether pre-fetch field authorization is active for this request. +func (a *FieldAuthorization) preFetchEnabled() bool { + return a.ctx.preFetchFieldAuthorizer != nil +} + +// authorizePreFetch resolves every protected field coordinate of the operation in one batch +// call and seeds the decision cache, before any fetch executes. It is a no-op when pre-fetch +// field authorization is disabled or the operation selects no protected field. +func (a *FieldAuthorization) authorizePreFetch(response *GraphQLResponse) error { + if a.ctx.preFetchFieldAuthorizer == nil || response == nil || response.Info == nil || len(response.Info.AuthorizationCoordinates) == 0 { + return nil + } + + coordinateIndex := make(map[GraphCoordinate]int, len(response.Info.AuthorizationCoordinates)) + coordinates := make([]GraphCoordinate, 0, len(response.Info.AuthorizationCoordinates)) + for i := range response.Info.AuthorizationCoordinates { + coordinate := response.Info.AuthorizationCoordinates[i].Coordinate + if _, exists := coordinateIndex[coordinate]; exists { + continue + } + coordinateIndex[coordinate] = len(coordinates) + coordinates = append(coordinates, coordinate) + } + decisions, err := a.ctx.preFetchFieldAuthorizer.AuthorizeFields(a.ctx, coordinates) + if err != nil { + return err + } + if len(decisions) != len(coordinates) { + return fmt.Errorf("batch authorizer returned %d decisions for %d coordinates", len(decisions), len(coordinates)) + } + for i := range response.Info.AuthorizationCoordinates { + authCoordinate := response.Info.AuthorizationCoordinates[i] + decision := decisions[coordinateIndex[authCoordinate.Coordinate]] + if decision.Allowed { + a.seedAllow(authCoordinate.DataSourceID, authCoordinate.Coordinate) + } else { + a.seedDeny(authCoordinate.DataSourceID, authCoordinate.Coordinate, decision.Reason) + } + } + return nil +} + +// decide returns the allow/deny decision for a field coordinate during response resolution, +// memoizing the result in the allow/deny cache. +// +// This cache is also what prevents a field from being authorized twice under pre-fetch field +// authorization. When that mode is enabled the batch authorizer decides every selected protected +// coordinate up front and seeds the cache (see authorizePreFetch), so the lookups below always +// hit and AuthorizeObjectField — the data-aware, post-fetch authorizer call — is never reached. +// AuthorizeObjectField therefore runs only in the default (disabled) mode, where no decisions +// are seeded and each coordinate is decided here on first encounter. +func (a *FieldAuthorization) decide(value *astjson.Value, dataSourceID string, coordinate GraphCoordinate) (result *AuthorizationDeny, err error) { + decisionID := authorizationDecisionID(dataSourceID, coordinate) + // Seeded (pre-fetch) or previously computed (post-fetch) decisions short-circuit here, so the + // post-fetch AuthorizeObjectField call below is skipped whenever a decision already exists. + if _, ok := a.allow[decisionID]; ok { + return nil, nil + } + if reason, ok := a.deny[decisionID]; ok { + return &AuthorizationDeny{Reason: reason}, nil + } + if a.ctx.authorizer == nil { + // Pre-fetch field authorization without a post-fetch authorizer: the only decisions are those + // seeded up front. A coordinate without a seeded decision is treated as authorized. + return nil, nil + } + a.marshalBuf = value.MarshalTo(a.marshalBuf[:0]) + result, err = a.ctx.authorizer.AuthorizeObjectField(a.ctx, dataSourceID, a.marshalBuf, coordinate) + if err != nil { + return nil, err + } + if result == nil { + a.allow[decisionID] = struct{}{} + } else { + a.deny[decisionID] = result.Reason + } + return result, nil +} + +func authorizationDecisionID(dataSourceID string, coordinate GraphCoordinate) uint64 { + // NUL delimiters keep the key unambiguous: without them distinct tuples like ("ab","c","d") and + // ("a","bc","d") would hash the same input and could reuse a decision for the wrong coordinate. + return xxhash.Sum64String(dataSourceID + "\x00" + coordinate.TypeName + "\x00" + coordinate.FieldName) +} + +func (a *FieldAuthorization) seedAllow(dataSourceID string, coordinate GraphCoordinate) { + a.allow[authorizationDecisionID(dataSourceID, coordinate)] = struct{}{} +} + +func (a *FieldAuthorization) seedDeny(dataSourceID string, coordinate GraphCoordinate, reason string) { + a.deny[authorizationDecisionID(dataSourceID, coordinate)] = reason +} + +func (a *FieldAuthorization) denyReason(dataSourceID string, coordinate GraphCoordinate) (string, bool) { + reason, ok := a.deny[authorizationDecisionID(dataSourceID, coordinate)] + return reason, ok +} diff --git a/v2/pkg/engine/resolve/loader.go b/v2/pkg/engine/resolve/loader.go index 61c04f2109..27088f5e39 100644 --- a/v2/pkg/engine/resolve/loader.go +++ b/v2/pkg/engine/resolve/loader.go @@ -159,6 +159,12 @@ type Loader struct { // dataBuffer holds the shared response tree and its concurrency guard. dataBuffer *DataBuffer + // authorization is set for the loader serving the primary response when pre-fetch field + // authorization is enabled. It holds the up-front batch decisions that isFetchAuthorizedFromCache + // reads to skip fetches serving only denied fields. It is nil for defer-group loaders, whose + // denied fields are still nulled/errored during response resolution. + authorization *FieldAuthorization + // errors accumulates fetch-time errors for this Loader instance. // Each parallel defer group gets its own Loader (via NewLoader) and so its // own errors. All writes happen under dataBuffer.Lock() (arena not thread-safe). @@ -1367,6 +1373,10 @@ func (l *Loader) renderRateLimitRejectedErrors(fetchItem *FetchItem, res *result } func (l *Loader) isFetchAuthorized(input []byte, info *FetchInfo, res *result) (authorized bool, err error) { + if l.ctx.preFetchFieldAuthorizer != nil { + operationType := l.fetchOperationType(info) + return l.isFetchAuthorizedFromCache(info, operationType, res), nil + } if info.OperationType == ast.OperationTypeQuery { // we only want to authorize Mutations and Subscriptions at the load level // Mutations can have side effects, so we don't want to send them to a subgraph if the user is not authorized @@ -1399,6 +1409,47 @@ func (l *Loader) isFetchAuthorized(input []byte, info *FetchInfo, res *result) ( return authorized, nil } +func (l *Loader) fetchOperationType(info *FetchInfo) ast.OperationType { + if info != nil && info.OperationType != ast.OperationTypeUnknown { + return info.OperationType + } + if l.info != nil { + return l.info.OperationType + } + return ast.OperationTypeUnknown +} + +func (l *Loader) isFetchAuthorizedFromCache(info *FetchInfo, operationType ast.OperationType, res *result) bool { + if l.authorization == nil || info == nil || len(info.RootFields) == 0 { + return true + } + deniedRootFields := 0 + for i := range info.RootFields { + if !info.RootFields[i].HasAuthorizationRule { + continue + } + _, denied := l.authorization.denyReason(info.DataSourceID, info.RootFields[i]) + if !denied { + continue + } + deniedRootFields++ + if operationType != ast.OperationTypeQuery { + // Mutations and subscriptions must not partially execute: skip the origin request + // entirely when any root field is denied. The single field-level + // UNAUTHORIZED_FIELD_OR_TYPE error is emitted during response resolution from the + // seeded decision (identical in shape to the query field errors); we intentionally do + // not set authorizationRejected here, which would add a second, subgraph-level error. + res.fetchSkipped = true + return false + } + } + if operationType == ast.OperationTypeQuery && deniedRootFields == len(info.RootFields) { + res.fetchSkipped = true + return false + } + return true +} + func (l *Loader) rateLimitFetch(input []byte, info *FetchInfo, res *result) (allowed bool, err error) { if !l.ctx.RateLimitOptions.Enable { return true, nil diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index a976355859..8be0c9abd9 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" - "github.com/cespare/xxhash/v2" "github.com/pkg/errors" "github.com/tidwall/gjson" @@ -88,19 +87,23 @@ type Resolvable struct { // ctx is the request Context (authorizer, rate limiter, field renderer, options). ctx *Context - // authorizationError holds an auth error raised mid-walk; - // in case of defer it is scoped to the current field/defer and converted into a defer local error. - authorizationError error + // authorization holds the per-request field-authorization decisions, shared with the Loader. + // Set via SetFieldAuthorization by the resolver entry points; lazily created in Init as a + // fallback for directly constructed Resolvables (tests). + authorization *FieldAuthorization - // xxh is a reused xxhash digest for computing authorization decision cache keys. - xxh *xxhash.Digest + // unreachedAuthWalk arms the synthetic authorization descent (pre-fetch mode, initial + // pre-render walk only): where the data ends but the plan continues, the walk descends the + // plan alone to emit errors for denied protected fields the data walk cannot reach. + unreachedAuthWalk bool - // authorizationAllow caches allowed authorization decision ids (keyed by the - // xxh of dataSource id + graph coordinate). - authorizationAllow map[uint64]struct{} + // inUnreachedSubtree is true while inside such a descent; ordinary null semantics are + // suppressed there — the walk only reads the decision cache and emits errors. + inUnreachedSubtree bool - // authorizationDeny caches denied authorization decision ids mapped to their deny reason. - authorizationDeny map[uint64]string + // authorizationError holds an auth error raised mid-walk; + // in case of defer it is scoped to the current field/defer and converted into a defer local error. + authorizationError error // wroteErrors records whether the `errors` array has been written to the response wroteErrors bool @@ -211,14 +214,19 @@ func MapExtensionForwardingAlgorithm(algorithm string) ExtensionForwardingAlgori func NewResolvable(a arena.Arena, options ResolvableOptions) *Resolvable { return &Resolvable{ - options: options, - xxh: xxhash.New(), - authorizationAllow: make(map[uint64]struct{}), - authorizationDeny: make(map[uint64]string), - astjsonArena: a, + options: options, + astjsonArena: a, + typeNameStats: make(map[string]TypeNameStats), } } +// SetFieldAuthorization wires the per-request field-authorization decisions produced and read +// during resolution. The resolver entry points call it right after NewResolvable; when unset, +// Init creates one from the request Context. +func (r *Resolvable) SetFieldAuthorization(authorization *FieldAuthorization) { + r.authorization = authorization +} + func (r *Resolvable) Reset() { r.parsers = r.parsers[:0] r.typeNames = r.typeNames[:0] @@ -236,13 +244,13 @@ func (r *Resolvable) Reset() { r.path = r.path[:0] r.operationType = ast.OperationTypeUnknown r.renameTypeNames = r.renameTypeNames[:0] + r.authorization = nil + r.unreachedAuthWalk = false + r.inUnreachedSubtree = false r.authorizationError = nil r.astjsonArena = nil - r.xxh.Reset() r.allowedExtensions = nil clear(r.subgraphExtensions) - clear(r.authorizationAllow) - clear(r.authorizationDeny) clear(r.typeNameStats) r.deferMode = false @@ -262,6 +270,9 @@ func (r *Resolvable) initCostControl() { func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.OperationType) (err error) { r.ctx = ctx + if r.authorization == nil { + r.authorization = NewFieldAuthorization(ctx) + } r.operationType = operationType r.renameTypeNames = ctx.RenameTypeNames r.initCostControl() @@ -283,6 +294,9 @@ func (r *Resolvable) Init(ctx *Context, initialData []byte, operationType ast.Op func (r *Resolvable) InitSubscription(ctx *Context, initialData []byte, postProcessing PostProcessingConfiguration) (err error) { r.ctx = ctx + if r.authorization == nil { + r.authorization = NewFieldAuthorization(ctx) + } r.operationType = ast.OperationTypeSubscription r.renameTypeNames = ctx.RenameTypeNames r.initCostControl() @@ -366,7 +380,15 @@ func (r *Resolvable) Resolve(ctx context.Context, rootData *Object, fetchTree *F r.skipAddingNullErrors = r.hasErrors() && !r.hasData() + if r.authorization.preFetchEnabled() { + // Also report denied protected fields the data walk cannot reach (empty list / null + // parent): past such points the walk descends the plan alone. A denied field stops the + // descent into its own subtree, so a denied parent is never re-reported via its children. + r.unreachedAuthWalk = true + } + hasErrors := r.walkObject(rootData, r.data) + r.unreachedAuthWalk = false if r.authorizationError != nil { return r.authorizationError } @@ -1215,6 +1237,15 @@ func (r *Resolvable) walkObject(obj *Object, parent *astjson.Value) (hasError bo }() value := parent.Get(obj.Path...) if value == nil || value.Type() == astjson.TypeNull { + if r.unreachedAuthWalk { + r.pushNodePathElement(obj.Path) + r.walkUnreachedFields(obj) + r.popNodePathElement(obj.Path) + if r.inUnreachedSubtree { + // synthetic level: no data to render or null-propagate + return false + } + } if obj.Nullable { return r.walkNull() } @@ -1540,7 +1571,7 @@ func (r *Resolvable) authorizeField(value *astjson.Value, field *Field) (skipFie if !field.Info.HasAuthorizationRule { return false } - if r.ctx.authorizer == nil { + if r.ctx.authorizer == nil && !r.authorization.preFetchEnabled() { return false } if len(field.Info.Source.IDs) == 0 { @@ -1549,11 +1580,14 @@ func (r *Resolvable) authorizeField(value *astjson.Value, field *Field) (skipFie dataSourceID := field.Info.Source.IDs[0] dataSourceName := field.Info.Source.Names[0] typeName := r.objectFieldTypeName(value, field) + if r.authorization.preFetchEnabled() { + typeName = field.Info.ExactParentTypeName + } gc := GraphCoordinate{ TypeName: typeName, FieldName: field.Info.Name, } - result, authErr := r.authorize(value, dataSourceID, gc) + result, authErr := r.authorization.decide(value, dataSourceID, gc) if authErr != nil { r.authorizationError = authErr return true @@ -1568,31 +1602,6 @@ func (r *Resolvable) authorizeField(value *astjson.Value, field *Field) (skipFie return false } -func (r *Resolvable) authorize(value *astjson.Value, dataSourceID string, coordinate GraphCoordinate) (result *AuthorizationDeny, err error) { - r.xxh.Reset() - _, _ = r.xxh.WriteString(dataSourceID) - _, _ = r.xxh.WriteString(coordinate.TypeName) - _, _ = r.xxh.WriteString(coordinate.FieldName) - decisionID := r.xxh.Sum64() - if _, ok := r.authorizationAllow[decisionID]; ok { - return nil, nil - } - if reason, ok := r.authorizationDeny[decisionID]; ok { - return &AuthorizationDeny{Reason: reason}, nil - } - r.marshalBuf = value.MarshalTo(r.marshalBuf[:0]) - result, err = r.ctx.authorizer.AuthorizeObjectField(r.ctx, dataSourceID, r.marshalBuf, coordinate) - if err != nil { - return nil, err - } - if result == nil { - r.authorizationAllow[decisionID] = struct{}{} - } else { - r.authorizationDeny[decisionID] = result.Reason - } - return result, nil -} - func (r *Resolvable) addRejectFieldError(reason string, ds DataSourceInfo, field *Field) { nodePath := field.Value.NodePath() r.pushNodePathElement(nodePath) @@ -1619,6 +1628,79 @@ func (r *Resolvable) objectFieldTypeName(v *astjson.Value, field *Field) string return field.Info.ExactParentTypeName } +// walkUnreachedFields descends the plan below a point the data walk cannot reach (null/missing +// object, empty array) and emits UNAUTHORIZED_FIELD_OR_TYPE errors for denied protected fields +// there. A denied field stops the descent — its error covers its subtree. Decisions are pure +// cache reads (pre-fetch mode only); no authorizer calls, no data mutation. Recursion goes +// through the regular walk functions with a null value, re-entering their null branches, so +// r.path — and with it error paths and messages — comes from the ordinary walk bookkeeping. +func (r *Resolvable) walkUnreachedFields(obj *Object) { + if obj == nil { + return + } + wasInside := r.inUnreachedSubtree + r.inUnreachedSubtree = true + for i := range obj.Fields { + field := obj.Fields[i] + if r.emitUnreachedFieldDeny(field) { + continue + } + switch field.Value.NodeKind() { + case NodeKindObject, NodeKindArray: + r.walkNode(field.Value, astjson.NullValue) + } + } + r.inUnreachedSubtree = wasInside +} + +// walkUnreachedItem descends into an array item the data walk has no element for (empty or null +// array) +func (r *Resolvable) walkUnreachedItem(item Node) { + switch item.NodeKind() { + case NodeKindObject, NodeKindArray: + default: + return + } + + wasInside := r.inUnreachedSubtree + r.inUnreachedSubtree = true + + // push the "@" wildcard position any element would occupy + r.pushNodePathElement([]string{"@"}) + r.walkNode(item, astjson.NullValue) + r.popNodePathElement([]string{"@"}) + + r.inUnreachedSubtree = wasInside +} + +// emitUnreachedFieldDeny reports whether the field carries a seeded deny decision, emitting the +// corresponding UNAUTHORIZED_FIELD_OR_TYPE error if so. +func (r *Resolvable) emitUnreachedFieldDeny(field *Field) bool { + if field.Info == nil || !field.Info.HasAuthorizationRule || len(field.Info.Source.IDs) == 0 { + return false + } + dataSourceID := field.Info.Source.IDs[0] + reason, denied := r.authorization.denyReason(dataSourceID, GraphCoordinate{ + TypeName: field.Info.ExactParentTypeName, + FieldName: field.Info.Name, + }) + if !denied { + return false + } + r.addRejectFieldError(reason, DataSourceInfo{ + ID: dataSourceID, + Name: firstString(field.Info.Source.Names), + }, field) + return true +} + +func firstString(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + func (r *Resolvable) skipFieldOnParentTypeNames(field *Field) bool { WithNext: for i := range field.ParentOnTypeNames { @@ -1661,6 +1743,15 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { parent := value value = value.Get(arr.Path...) if astjson.ValueIsNull(value) { + if r.unreachedAuthWalk { + r.pushNodePathElement(arr.Path) + r.walkUnreachedItem(arr.Item) + r.popNodePathElement(arr.Path) + if r.inUnreachedSubtree { + // synthetic level: no data to render or null-propagate + return false + } + } if arr.Nullable { return r.walkNull() } @@ -1678,6 +1769,11 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { } values := value.GetArray() + if len(values) == 0 && r.unreachedAuthWalk && !r.inUnreachedSubtree { + // no elements to walk: check the item's plan subtree for denied protected fields + r.walkUnreachedItem(arr.Item) + } + if !r.render() && r.options.EnableCostControl { // Record arrays stats for Cost Control. pathKey := r.currentFieldPath() diff --git a/v2/pkg/engine/resolve/resolvable_authorization_test.go b/v2/pkg/engine/resolve/resolvable_authorization_test.go new file mode 100644 index 0000000000..a64369121f --- /dev/null +++ b/v2/pkg/engine/resolve/resolvable_authorization_test.go @@ -0,0 +1,786 @@ +package resolve + +import ( + "bytes" + "context" + "encoding/json" + stderrors "errors" + "io" + "net/http" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/astjson" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" +) + +type resolvableAuthorizationAuthorizer struct { + authorizeObjectField func(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) + objectFieldCalls int + seenCoordinates []GraphCoordinate +} + +type resolvableAuthorizationDataSource struct { + data []byte +} + +func (d resolvableAuthorizationDataSource) Load(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + return d.data, nil +} + +func (d resolvableAuthorizationDataSource) LoadWithFiles(ctx context.Context, headers http.Header, input []byte, files []*httpclient.FileUpload) ([]byte, error) { + return d.data, nil +} + +func (a *resolvableAuthorizationAuthorizer) AuthorizePreFetch(ctx *Context, dataSourceID string, input json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) { + return nil, nil +} + +func (a *resolvableAuthorizationAuthorizer) AuthorizeObjectField(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) { + a.objectFieldCalls++ + a.seenCoordinates = append(a.seenCoordinates, coordinate) + if a.authorizeObjectField == nil { + return nil, nil + } + return a.authorizeObjectField(ctx, dataSourceID, object, coordinate) +} + +func (a *resolvableAuthorizationAuthorizer) HasResponseExtensionData(ctx *Context) bool { + return false +} + +func (a *resolvableAuthorizationAuthorizer) RenderResponseExtension(ctx *Context, out io.Writer) error { + return nil +} + +func TestResolvableAuthorizationAuthorizeField(t *testing.T) { + value := mustParseAuthorizationValue(t, `{"__typename":"ActualParent","name":"Ada"}`) + baseField := func() *Field { + return &Field{ + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "DeclaredParent", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"Accounts"}, + }, + }, + Value: &String{Path: []string{"secret"}}, + } + } + + t.Run("field info nil", func(t *testing.T) { + resolvable := newResolvableForAuthorizationTest(NewContext(context.Background())) + assert.False(t, resolvable.authorizeField(value, &Field{})) + }) + + t.Run("field has no authorization rule", func(t *testing.T) { + resolvable := newResolvableForAuthorizationTest(NewContext(context.Background())) + field := baseField() + field.Info.HasAuthorizationRule = false + assert.False(t, resolvable.authorizeField(value, field)) + }) + + t.Run("no authorizers", func(t *testing.T) { + resolvable := newResolvableForAuthorizationTest(NewContext(context.Background())) + assert.False(t, resolvable.authorizeField(value, baseField())) + }) + + t.Run("empty source IDs", func(t *testing.T) { + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(&resolvableAuthorizationAuthorizer{}) + resolvable := newResolvableForAuthorizationTest(ctx) + field := baseField() + field.Info.Source.IDs = nil + assert.False(t, resolvable.authorizeField(value, field)) + }) + + t.Run("allowed", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{} + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + + assert.False(t, resolvable.authorizeField(value, baseField())) + require.Len(t, authorizer.seenCoordinates, 1) + assert.Equal(t, GraphCoordinate{TypeName: "ActualParent", FieldName: "secret"}, authorizer.seenCoordinates[0]) + }) + + t.Run("denied adds reject error", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{ + authorizeObjectField: func(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) { + return &AuthorizationDeny{Reason: "missing scope"}, nil + }, + } + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + + assert.True(t, resolvable.authorizeField(value, baseField())) + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.secret', Reason: missing scope.","path":["secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + require.Error(t, ctx.subgraphErrors["Accounts"]) + }) + + t.Run("pre-fetch authorizer uses exact parent type name", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{} + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + ctx.SetPreFetchFieldAuthorizer(&batchTestAuthorizer{}) + resolvable := newResolvableForAuthorizationTest(ctx) + + assert.False(t, resolvable.authorizeField(value, baseField())) + require.Len(t, authorizer.seenCoordinates, 1) + assert.Equal(t, GraphCoordinate{TypeName: "DeclaredParent", FieldName: "secret"}, authorizer.seenCoordinates[0]) + }) + + t.Run("authorization error", func(t *testing.T) { + authErr := stderrors.New("authorizer failed") + authorizer := &resolvableAuthorizationAuthorizer{ + authorizeObjectField: func(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) { + return nil, authErr + }, + } + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + + assert.True(t, resolvable.authorizeField(value, baseField())) + assert.ErrorIs(t, resolvable.authorizationError, authErr) + }) +} + +func TestResolvableAuthorizationAuthorize(t *testing.T) { + value := mustParseAuthorizationValue(t, `{"__typename":"User","name":"Ada"}`) + coordinate := GraphCoordinate{TypeName: "User", FieldName: "name"} + + t.Run("allow cache hit", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{} + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + resolvable.authorization.seedAllow("users", coordinate) + + result, err := resolvable.authorization.decide(value, "users", coordinate) + require.NoError(t, err) + assert.Nil(t, result) + assert.Equal(t, 0, authorizer.objectFieldCalls) + }) + + t.Run("deny cache hit", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{} + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + resolvable.authorization.seedDeny("users", coordinate, "cached deny") + + result, err := resolvable.authorization.decide(value, "users", coordinate) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "cached deny", result.Reason) + assert.Equal(t, 0, authorizer.objectFieldCalls) + }) + + t.Run("nil authorizer miss allows", func(t *testing.T) { + resolvable := newResolvableForAuthorizationTest(NewContext(context.Background())) + + result, err := resolvable.authorization.decide(value, "users", coordinate) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("authorizer allows and seeds cache", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{} + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + + result, err := resolvable.authorization.decide(value, "users", coordinate) + require.NoError(t, err) + assert.Nil(t, result) + + _, ok := resolvable.authorization.allow[authorizationDecisionID("users", coordinate)] + assert.True(t, ok) + }) + + t.Run("authorizer denies and seeds cache", func(t *testing.T) { + authorizer := &resolvableAuthorizationAuthorizer{ + authorizeObjectField: func(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) { + return &AuthorizationDeny{Reason: "denied"}, nil + }, + } + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + + result, err := resolvable.authorization.decide(value, "users", coordinate) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "denied", result.Reason) + + reason, ok := resolvable.authorization.deny[authorizationDecisionID("users", coordinate)] + assert.True(t, ok) + assert.Equal(t, "denied", reason) + }) + + t.Run("authorizer error", func(t *testing.T) { + authErr := stderrors.New("boom") + authorizer := &resolvableAuthorizationAuthorizer{ + authorizeObjectField: func(ctx *Context, dataSourceID string, object json.RawMessage, coordinate GraphCoordinate) (*AuthorizationDeny, error) { + return nil, authErr + }, + } + ctx := NewContext(context.Background()) + ctx.SetAuthorizer(authorizer) + resolvable := newResolvableForAuthorizationTest(ctx) + + result, err := resolvable.authorization.decide(value, "users", coordinate) + assert.Nil(t, result) + assert.ErrorIs(t, err, authErr) + }) +} + +func TestFieldAuthorizationDecisionSeeding(t *testing.T) { + authorization := NewFieldAuthorization(NewContext(context.Background())) + coordinate := GraphCoordinate{TypeName: "User", FieldName: "email"} + + assert.Equal(t, authorizationDecisionID("users", coordinate), authorizationDecisionID("users", coordinate)) + assert.NotEqual(t, authorizationDecisionID("users", coordinate), authorizationDecisionID("profiles", coordinate)) + + authorization.seedAllow("users", coordinate) + _, allowed := authorization.allow[authorizationDecisionID("users", coordinate)] + assert.True(t, allowed) + + authorization.seedDeny("users", coordinate, "missing email scope") + reason, denied := authorization.denyReason("users", coordinate) + assert.True(t, denied) + assert.Equal(t, "missing email scope", reason) + + reason, denied = authorization.denyReason("users", GraphCoordinate{TypeName: "User", FieldName: "name"}) + assert.False(t, denied) + assert.Empty(t, reason) +} + +func TestResolvableAuthorizationUnreachedData(t *testing.T) { + // newPreFetchResolvable enables pre-fetch mode on the context; subtests seed decisions directly. + newPreFetchResolvable := func() *Resolvable { + ctx := NewContext(context.Background()) + ctx.SetPreFetchFieldAuthorizer(&batchTestAuthorizer{}) + return newResolvableForAuthorizationTest(ctx) + } + + // walkUnreached runs the pre-render walk with the synthetic authorization descent armed, + // exactly as Resolve does for the initial walk in pre-fetch mode. + walkUnreached := func(t *testing.T, resolvable *Resolvable, root *Object, data *astjson.Value) { + t.Helper() + resolvable.unreachedAuthWalk = true + resolvable.walkObject(root, data) + resolvable.unreachedAuthWalk = false + } + + t.Run("empty list emits nested denied field at the list wildcard", func(t *testing.T) { + resolvable := newPreFetchResolvable() + resolvable.authorization.seedDeny("products", GraphCoordinate{TypeName: "Product", FieldName: "secret"}, "missing product scope") + root := &Object{ + Fields: []*Field{ + { + Name: []byte("products"), + Value: &Array{ + Path: []string{"products"}, + Item: &Object{ + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Product", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"products"}, + Names: []string{"products"}, + }, + }, + Value: &String{Path: []string{"secret"}}, + }, + }, + }, + }, + }, + }, + } + data := mustParseAuthorizationValue(t, `{"products":[]}`) + + walkUnreached(t, resolvable, root, data) + + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.products.@.secret', Reason: missing product scope.","path":["products","@","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + }) + + t.Run("null nullable parent emits denied nested field", func(t *testing.T) { + resolvable := newPreFetchResolvable() + resolvable.authorization.seedDeny("accounts", GraphCoordinate{TypeName: "Account", FieldName: "secret"}, "missing account scope") + root := &Object{ + Fields: []*Field{ + { + Name: []byte("account"), + Value: &Object{ + Path: []string{"account"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Account", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"Accounts"}, + }, + }, + Value: &String{Path: []string{"secret"}, Nullable: true}, + }, + }, + }, + }, + }, + } + data := mustParseAuthorizationValue(t, `{"account":null}`) + + walkUnreached(t, resolvable, root, data) + + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.account.secret', Reason: missing account scope.","path":["account","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + }) + + t.Run("reached child is denied by the walk itself, not the synthetic descent", func(t *testing.T) { + resolvable := newPreFetchResolvable() + resolvable.authorization.seedDeny("accounts", GraphCoordinate{TypeName: "Account", FieldName: "secret"}, "missing account scope") + root := &Object{ + Fields: []*Field{ + { + Name: []byte("account"), + Value: &Object{ + Path: []string{"account"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Account", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"Accounts"}, + }, + }, + Value: &String{Path: []string{"secret"}, Nullable: true}, + }, + }, + }, + }, + }, + } + data := mustParseAuthorizationValue(t, `{"account":{"secret":"hidden"}}`) + + walkUnreached(t, resolvable, root, data) + + // exactly one error: the data walk owns reached fields, the synthetic descent stays out + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.account.secret', Reason: missing account scope.","path":["account","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + }) + + t.Run("denied frontier field stops the synthetic descent into its subtree", func(t *testing.T) { + resolvable := newPreFetchResolvable() + resolvable.authorization.seedDeny("accounts", GraphCoordinate{TypeName: "Account", FieldName: "vault"}, "missing vault scope") + resolvable.authorization.seedDeny("accounts", GraphCoordinate{TypeName: "Vault", FieldName: "secret"}, "missing secret scope") + root := &Object{ + Fields: []*Field{ + { + Name: []byte("account"), + Value: &Object{ + Path: []string{"account"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("vault"), + Info: &FieldInfo{ + Name: "vault", + ExactParentTypeName: "Account", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"Accounts"}, + }, + }, + Value: &Object{ + Path: []string{"vault"}, + Nullable: true, + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Vault", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"accounts"}, + Names: []string{"Accounts"}, + }, + }, + Value: &String{Path: []string{"secret"}, Nullable: true}, + }, + }, + }, + }, + }, + }, + }, + }, + } + data := mustParseAuthorizationValue(t, `{"account":null}`) + + walkUnreached(t, resolvable, root, data) + + // the denied vault covers its subtree: no Vault.secret error + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.account.vault', Reason: missing vault scope.","path":["account","vault"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + }) + + t.Run("nested empty arrays recurse with a wildcard per list level", func(t *testing.T) { + resolvable := newPreFetchResolvable() + resolvable.authorization.seedDeny("products", GraphCoordinate{TypeName: "Product", FieldName: "secret"}, "missing product scope") + root := &Object{ + Fields: []*Field{ + { + Name: []byte("edges"), + Value: &Array{ + Path: []string{"edges"}, + Nullable: true, + Item: &Array{ + Path: []string{"nodes"}, + Nullable: true, + Item: &Object{ + Nullable: true, + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Product", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"products"}, + Names: []string{"products"}, + }, + }, + Value: &String{Path: []string{"secret"}, Nullable: true}, + }, + }, + }, + }, + }, + }, + }, + } + data := mustParseAuthorizationValue(t, `{"edges":[]}`) + + walkUnreached(t, resolvable, root, data) + + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.edges.@.nodes.@.secret', Reason: missing product scope.","path":["edges","@","nodes","@","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + }) +} + +type countingAuthorizationDataSource struct { + loads *atomic.Int64 + data []byte +} + +func (d countingAuthorizationDataSource) Load(ctx context.Context, headers http.Header, input []byte) ([]byte, error) { + d.loads.Add(1) + return d.data, nil +} + +func (d countingAuthorizationDataSource) LoadWithFiles(ctx context.Context, headers http.Header, input []byte, files []*httpclient.FileUpload) ([]byte, error) { + d.loads.Add(1) + return d.data, nil +} + +// deepOrdersResponse models: query { orders { total items { product { secret pricing { internal } } } } } +// with every field served by the "shop" data source. Protected coordinates: Order.total, +// Product.secret, Pricing.internal — plus Query.orders itself when rootProtected is true. +func deepOrdersResponse(service DataSource, rootProtected bool) *GraphQLResponse { + ordersInfo := &FieldInfo{ + Name: "orders", + ExactParentTypeName: "Query", + Source: TypeFieldSource{IDs: []string{"shop"}, Names: []string{"shop"}}, + } + coordinates := []AuthorizationCoordinate{ + {DataSourceID: "shop", Coordinate: GraphCoordinate{TypeName: "Order", FieldName: "total"}}, + {DataSourceID: "shop", Coordinate: GraphCoordinate{TypeName: "Pricing", FieldName: "internal"}}, + {DataSourceID: "shop", Coordinate: GraphCoordinate{TypeName: "Product", FieldName: "secret"}}, + } + rootField := GraphCoordinate{TypeName: "Query", FieldName: "orders"} + if rootProtected { + ordersInfo.HasAuthorizationRule = true + rootField.HasAuthorizationRule = true + coordinates = append(coordinates, AuthorizationCoordinate{DataSourceID: "shop", Coordinate: GraphCoordinate{TypeName: "Query", FieldName: "orders"}}) + } + return &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + AuthorizationCoordinates: coordinates, + }, + Fetches: Single(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: service, + PostProcessing: PostProcessingConfiguration{ + SelectResponseDataPath: []string{"data"}, + SelectResponseErrorsPath: []string{"errors"}, + }, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + {SegmentType: StaticSegmentType, Data: []byte(`{}`)}, + }, + }, + Info: &FetchInfo{ + DataSourceID: "shop", + DataSourceName: "shop", + RootFields: []GraphCoordinate{rootField}, + }, + }), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("orders"), + Info: ordersInfo, + Value: &Array{ + Path: []string{"orders"}, + Nullable: true, + Item: &Object{ + Nullable: true, + TypeName: "Order", + Fields: []*Field{ + { + Name: []byte("total"), + Info: &FieldInfo{ + Name: "total", + ExactParentTypeName: "Order", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"shop"}, + Names: []string{"shop"}, + }, + }, + Value: &String{Path: []string{"total"}}, + }, + { + Name: []byte("items"), + Value: &Array{ + Path: []string{"items"}, + Item: &Object{ + TypeName: "Item", + Fields: []*Field{ + { + Name: []byte("product"), + Value: &Object{ + Path: []string{"product"}, + TypeName: "Product", + Fields: []*Field{ + { + Name: []byte("secret"), + Info: &FieldInfo{ + Name: "secret", + ExactParentTypeName: "Product", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"shop"}, + Names: []string{"shop"}, + }, + }, + Value: &String{Path: []string{"secret"}, Nullable: true}, + }, + { + Name: []byte("pricing"), + Value: &Object{ + Path: []string{"pricing"}, + Nullable: true, + TypeName: "Pricing", + Fields: []*Field{ + { + Name: []byte("internal"), + Info: &FieldInfo{ + Name: "internal", + ExactParentTypeName: "Pricing", + HasAuthorizationRule: true, + Source: TypeFieldSource{ + IDs: []string{"shop"}, + Names: []string{"shop"}, + }, + }, + Value: &String{Path: []string{"internal"}, Nullable: true}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestResolvableAuthorizationEndToEnd(t *testing.T) { + t.Run("empty list emits nested denied field", func(t *testing.T) { + service := resolvableAuthorizationDataSource{data: []byte(`{"data":{"products":[]}}`)} + response := productsSecretResponse(service) + response.Info.AuthorizationCoordinates = []AuthorizationCoordinate{ + {DataSourceID: "products", Coordinate: GraphCoordinate{TypeName: "Product", FieldName: "secret"}}, + } + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Product", FieldName: "secret"}: {Allowed: false, Reason: "missing product scope"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.products.@.secret', Reason: missing product scope.","path":["products","@","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"products":[]}}`, buf.String()) + }) + + // The two subtests below exercise the interplay between the synthetic (unreached-data) + // descent and the auth walk on one deep response tree (orders -> items -> product -> pricing). + + t.Run("fetch runs, mixed reached and unreached branches", func(t *testing.T) { + // orders[0] is reached: the walk denies+nulls secret; its null pricing hides + // Pricing.internal, reported by the synthetic descent. orders[1].items is empty: both + // denied fields are reported there at the "@" wildcard. Order.total is allowed: no error. + loads := &atomic.Int64{} + service := countingAuthorizationDataSource{ + loads: loads, + data: []byte(`{"data":{"orders":[{"total":"a","items":[{"product":{"secret":"classified","pricing":null}}]},{"total":"b","items":[]}]}}`), + } + response := deepOrdersResponse(service, false) + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Product", FieldName: "secret"}: {Allowed: false, Reason: "missing product scope"}, + {TypeName: "Pricing", FieldName: "internal"}: {Allowed: false, Reason: "missing pricing scope"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.orders.items.product.secret', Reason: missing product scope.","path":["orders",0,"items",0,"product","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.orders.items.product.pricing.internal', Reason: missing pricing scope.","path":["orders",0,"items",0,"product","pricing","internal"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.orders.items.@.product.secret', Reason: missing product scope.","path":["orders",1,"items","@","product","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}},{"message":"Unauthorized to load field 'Query.orders.items.@.product.pricing.internal', Reason: missing pricing scope.","path":["orders",1,"items","@","product","pricing","internal"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"orders":[{"total":"a","items":[{"product":{"secret":null,"pricing":null}}]},{"total":"b","items":[]}]}}`, buf.String()) + assert.Equal(t, int64(1), loads.Load()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) + + t.Run("denied root prevents fetch, nested denials still reported", func(t *testing.T) { + // Query.orders is the fetch's only root field and is denied, so the fetch is skipped. + // The walk still reaches orders itself (the root object always has data), emits the deny + // and nulls it; its error covers the subtree, so Product.secret is not reported. + loads := &atomic.Int64{} + service := countingAuthorizationDataSource{ + loads: loads, + data: []byte(`{"data":{"orders":[{"total":"leak","items":[{"product":{"secret":"leak","pricing":{"internal":"leak"}}}]}]}}`), + } + response := deepOrdersResponse(service, true) + authorizer := &batchTestAuthorizer{ + decisions: map[GraphCoordinate]AuthorizationDecision{ + {TypeName: "Query", FieldName: "orders"}: {Allowed: false, Reason: "missing orders scope"}, + {TypeName: "Product", FieldName: "secret"}: {Allowed: false, Reason: "missing product scope"}, + }, + } + resolveCtx := NewContext(context.Background()) + resolveCtx.SetPreFetchFieldAuthorizer(authorizer) + + var buf bytes.Buffer + resolver := newResolver(context.Background()) + _, err := resolver.ResolveGraphQLResponse(resolveCtx, response, nil, &buf) + require.NoError(t, err) + + assert.Equal(t, `{"errors":[{"message":"Unauthorized to load field 'Query.orders', Reason: missing orders scope.","path":["orders"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}],"data":{"orders":null}}`, buf.String()) + assert.Equal(t, int64(0), loads.Load()) + assert.Equal(t, int64(1), authorizer.batchCalls.Load()) + assert.Equal(t, int64(0), authorizer.objectFieldCalls.Load()) + }) +} + +func TestResolvableAuthorizationHelpers(t *testing.T) { + t.Run("first string", func(t *testing.T) { + assert.Empty(t, firstString(nil)) + assert.Equal(t, "first", firstString([]string{"first", "second"})) + }) +} + +func TestResolvableAuthorizationRejectErrors(t *testing.T) { + field := &Field{Name: []byte("secret"), Value: &String{Path: []string{"account", "secret"}}} + + t.Run("field error with reason", func(t *testing.T) { + resolvable := newResolvableForAuthorizationTest(NewContext(context.Background())) + + resolvable.addRejectFieldError("missing scope", DataSourceInfo{ID: "accounts", Name: "Accounts"}, field) + + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.account.secret', Reason: missing scope.","path":["account","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + require.Error(t, resolvable.ctx.subgraphErrors["Accounts"]) + }) + + t.Run("field error without reason", func(t *testing.T) { + resolvable := newResolvableForAuthorizationTest(NewContext(context.Background())) + + resolvable.addRejectFieldError("", DataSourceInfo{ID: "accounts", Name: "Accounts"}, field) + + assert.Equal(t, `[{"message":"Unauthorized to load field 'Query.account.secret'.","path":["account","secret"],"extensions":{"code":"UNAUTHORIZED_FIELD_OR_TYPE"}}]`, resolvable.errors.String()) + }) + +} + +func TestResolvableAuthorizationObjectFieldTypeName(t *testing.T) { + resolvable := NewResolvable(nil, ResolvableOptions{}) + field := &Field{ + Info: &FieldInfo{ExactParentTypeName: "Fallback"}, + } + + assert.Equal(t, "Concrete", resolvable.objectFieldTypeName(mustParseAuthorizationValue(t, `{"__typename":"Concrete"}`), field)) + assert.Equal(t, "Fallback", resolvable.objectFieldTypeName(mustParseAuthorizationValue(t, `{}`), field)) +} + +func newResolvableForAuthorizationTest(ctx *Context) *Resolvable { + resolvable := NewResolvable(nil, ResolvableOptions{}) + requireNoError := resolvable.Init(ctx, nil, ast.OperationTypeQuery) + if requireNoError != nil { + panic(requireNoError) + } + return resolvable +} + +func mustParseAuthorizationValue(t *testing.T, data string) *astjson.Value { + t.Helper() + value, err := astjson.ParseBytes([]byte(data)) + require.NoError(t, err) + return value +} diff --git a/v2/pkg/engine/resolve/resolve.go b/v2/pkg/engine/resolve/resolve.go index b51ff7b3b2..8af64b561f 100644 --- a/v2/pkg/engine/resolve/resolve.go +++ b/v2/pkg/engine/resolve/resolve.go @@ -20,6 +20,7 @@ import ( "github.com/wundergraph/go-arena" + "github.com/wundergraph/graphql-go-tools/v2/pkg/errorcodes" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/xcontext" "github.com/wundergraph/graphql-go-tools/v2/pkg/pool" ) @@ -315,10 +316,11 @@ func New(ctx context.Context, options ResolverOptions) *Resolver { return resolver } -func NewLoader(options ResolverOptions, allowedExtensionFields map[string]struct{}, allowedErrorFields map[string]struct{}, sf *SubgraphRequestSingleFlight, a arena.Arena, db *DataBuffer) *Loader { +func NewLoader(options ResolverOptions, allowedExtensionFields map[string]struct{}, allowedErrorFields map[string]struct{}, sf *SubgraphRequestSingleFlight, a arena.Arena, db *DataBuffer, authorization *FieldAuthorization) *Loader { return &Loader{ - dataBuffer: db, - apolloCompatibilitySuppressFetchErrors: options.ResolvableOptions.ApolloCompatibilitySuppressFetchErrors, + dataBuffer: db, + authorization: authorization, + apolloCompatibilitySuppressFetchErrors: options.ResolvableOptions.ApolloCompatibilitySuppressFetchErrors, apolloCompatibilityValueCompletionInExtensions: options.ResolvableOptions.ApolloCompatibilityValueCompletionInExtensions, allowCustomExtensionProperties: options.AllowCustomExtensionProperties, propagateSubgraphErrors: options.PropagateSubgraphErrors, @@ -370,6 +372,8 @@ func (r *Resolver) ResolveGraphQLResponse(ctx *Context, response *GraphQLRespons }() resolvable := NewResolvable(nil, r.options.ResolvableOptions) + authorization := NewFieldAuthorization(ctx) + resolvable.SetFieldAuthorization(authorization) err := resolvable.Init(ctx, data, response.Info.OperationType) if err != nil { @@ -379,9 +383,15 @@ func (r *Resolver) ResolveGraphQLResponse(ctx *Context, response *GraphQLRespons // The DataBuffer wraps the base tree produced by Init (which may already // contain initialData). The loader fetches/merges into it. db := &DataBuffer{data: resolvable.data} - loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, nil, db) + loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, nil, db, authorization) if !ctx.ExecutionOptions.SkipLoader { + // Pre-fetch field authorization only matters when fetches actually run. When the loader is + // skipped (e.g. query-plan-only responses) there are no origin fetches, so we must not invoke + // the authorizer here. + if err = authorization.authorizePreFetch(response); err != nil { + return nil, err + } err = loader.LoadGraphQLResponseData(ctx, response) if err != nil { return nil, err @@ -438,6 +448,8 @@ func (r *Resolver) ArenaResolveGraphQLResponse(ctx *Context, response *GraphQLRe resolveArena := r.resolveArenaPool.Acquire(ctx.Request.ID) // we're intentionally not using defer Release to have more control over the timing (see below) resolvable := NewResolvable(resolveArena.Arena, r.options.ResolvableOptions) + authorization := NewFieldAuthorization(ctx) + resolvable.SetFieldAuthorization(authorization) err = resolvable.Init(ctx, nil, response.Info.OperationType) if err != nil { @@ -448,9 +460,17 @@ func (r *Resolver) ArenaResolveGraphQLResponse(ctx *Context, response *GraphQLRe // The DataBuffer wraps the base tree produced by Init. The loader merges into it. db := &DataBuffer{data: resolvable.data} - loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, resolveArena.Arena, db) + loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, resolveArena.Arena, db, authorization) if !ctx.ExecutionOptions.SkipLoader { + // Pre-fetch field authorization only matters when fetches actually run. When the loader is + // skipped (e.g. query-plan-only responses) there are no origin fetches, so we must not invoke + // the authorizer here. + if err = authorization.authorizePreFetch(response); err != nil { + r.inboundRequestSingleFlight.FinishErr(inflight, err) + r.resolveArenaPool.Release(resolveArena) + return nil, err + } err = loader.LoadGraphQLResponseData(ctx, response) if err != nil { r.inboundRequestSingleFlight.FinishErr(inflight, err) @@ -528,6 +548,8 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe defer r.resolveArenaPool.Release(resolveArena) resolvable := NewResolvable(resolveArena.Arena, r.options.ResolvableOptions) + authorization := NewFieldAuthorization(ctx) + resolvable.SetFieldAuthorization(authorization) err := resolvable.Init(ctx, nil, response.Response.Info.OperationType) if err != nil { @@ -537,9 +559,17 @@ func (r *Resolver) ResolveGraphQLDeferResponse(ctx *Context, response *GraphQLDe // The DataBuffer wraps the base tree produced by Init. The loader and every // defer group merge into it. db := &DataBuffer{data: resolvable.data} - loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, resolveArena.Arena, db) + loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, resolveArena.Arena, db, authorization) if !ctx.ExecutionOptions.SkipLoader { + // Pre-fetch field authorization: seed the batch decisions before the initial fetch, so denied + // fields are skipped/nulled during the initial and deferred renders, matching the + // non-deferred paths. The seeded decisions are shared with the resolvable and cover every + // selected coordinate, including those inside @defer fragments. + if err := authorization.authorizePreFetch(response.Response); err != nil { + return nil, err + } + loader.Init(ctx, response.Response.Info) // fetch initial response @@ -645,7 +675,7 @@ func (r *Resolver) resolveDeferSingle(dc *deferContext, ctx *Context, group *Def // the arena only in its prepare and merge phases, both of which hold // dc.db.Lock(), and the off-lock network phase allocates nothing from it. The // lock therefore serialises every arena allocation across all groups. - groupLoader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, dc.arena, dc.db) + groupLoader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, dc.arena, dc.db, nil) groupLoader.Init(ctx, dc.info) // fresh taintedObjs; errors=nil if fetchErr := groupLoader.ResolveFetchNode(group.Fetches); fetchErr != nil { @@ -753,6 +783,44 @@ func (r *Resolver) resolveDeferTree(dc *deferContext, ctx *Context, node *DeferT return nil } +// authorizeSubscriptionPreFetch authorizes a subscription's single protected root field before the +// trigger is started, so an unauthorized subscription never opens (or holds) an upstream subscription. +// It returns the response body to write and true when the subscription is unauthorized. Nested +// protected fields are still authorized per update during resolution. +func (r *Resolver) authorizeSubscriptionPreFetch(ctx *Context, response *GraphQLResponse) (deny []byte, denied bool, err error) { + if ctx.preFetchFieldAuthorizer == nil { + return nil, false, nil + } + if response == nil || response.Data == nil || len(response.Data.Fields) == 0 { + return nil, false, nil + } + rootField := response.Data.Fields[0] + if rootField.Info == nil || !rootField.Info.HasAuthorizationRule || len(rootField.Info.Source.IDs) == 0 { + return nil, false, nil + } + coordinate := GraphCoordinate{ + TypeName: rootField.Info.ExactParentTypeName, + FieldName: rootField.Info.Name, + } + decisions, err := ctx.preFetchFieldAuthorizer.AuthorizeFields(ctx, []GraphCoordinate{coordinate}) + if err != nil { + return nil, false, err + } + // Fail closed: a wrong decision count is an authorizer bug, not an authorization grant. + if len(decisions) != 1 { + return nil, false, fmt.Errorf("pre-fetch field authorizer returned %d decisions for 1 coordinate", len(decisions)) + } + if decisions[0].Allowed { + return nil, false, nil + } + message := fmt.Sprintf("Unauthorized to load field '%s.%s'.", coordinate.TypeName, coordinate.FieldName) + if decisions[0].Reason != "" { + message = fmt.Sprintf("Unauthorized to load field '%s.%s', Reason: %s.", coordinate.TypeName, coordinate.FieldName, decisions[0].Reason) + } + body := fmt.Sprintf(`{"errors":[{"message":%q,"extensions":{"code":%q}}],"data":null}`, message, errorcodes.UnauthorizedFieldOrType) + return []byte(body), true, nil +} + // trigger groups subscriptions that share a data source and input. type trigger struct { // mu protects subscriptions. @@ -927,6 +995,8 @@ func (r *Resolver) executeSubscriptionUpdate(resolveCtx *Context, sub *subscript resolveArena := r.resolveArenaPool.Acquire(resolveCtx.Request.ID) resolvable := NewResolvable(resolveArena.Arena, r.options.ResolvableOptions) + authorization := NewFieldAuthorization(resolveCtx) + resolvable.SetFieldAuthorization(authorization) if err := resolvable.InitSubscription(resolveCtx, input, sub.resolve.Trigger.PostProcessing); err != nil { r.resolveArenaPool.Release(resolveArena) @@ -939,11 +1009,22 @@ func (r *Resolver) executeSubscriptionUpdate(resolveCtx *Context, sub *subscript } return } + if err := authorization.authorizePreFetch(sub.resolve.Response); err != nil { + r.resolveArenaPool.Release(resolveArena) + sub.writeError(r.errorFormatter, resolveCtx, err, sub.resolve.Response) + if r.options.Debug { + fmt.Printf("resolver:trigger:subscription:authorization:failed:%d\n", sub.id.SubscriptionID) + } + if r.reporter != nil { + r.reporter.SubscriptionUpdateSent() + } + return + } // The DataBuffer wraps the base tree produced by InitSubscription (the // subscription event payload). The loader merges fetched data into it. db := &DataBuffer{data: resolvable.data} - loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, resolveArena.Arena, db) + loader := NewLoader(r.options, r.allowedErrorExtensionFields, r.allowedErrorFields, r.subgraphRequestSingleFlight, resolveArena.Arena, db, authorization) if err := loader.LoadGraphQLResponseData(resolveCtx, sub.resolve.Response); err != nil { r.resolveArenaPool.Release(resolveArena) @@ -1652,6 +1733,14 @@ func (r *Resolver) ResolveGraphQLSubscription(ctx *Context, subscription *GraphQ return nil } + // Authorize the subscription's protected root field before starting the trigger, so an + // unauthorized subscription never opens an upstream subscription. + if body, denied, authErr := r.authorizeSubscriptionPreFetch(ctx, subscription.Response); authErr != nil { + return authErr + } else if denied { + return writeFlushComplete(writer, body) + } + if hook, ok := subscription.Trigger.Source.(HookablePubsubDatasource); ok { input, err = hook.SubscriptionOnCreate(ctx.Context(), input) if err != nil { @@ -1758,6 +1847,14 @@ func (r *Resolver) AsyncResolveGraphQLSubscription(ctx *Context, subscription *G return err } + // Authorize the subscription's protected root field before starting the trigger, so an + // unauthorized subscription never opens an upstream subscription. + if body, denied, authErr := r.authorizeSubscriptionPreFetch(ctx, subscription.Response); authErr != nil { + return authErr + } else if denied { + return writeFlushComplete(writer, body) + } + if hook, ok := subscription.Trigger.Source.(HookablePubsubDatasource); ok { input, err = hook.SubscriptionOnCreate(ctx.Context(), input) if err != nil { diff --git a/v2/pkg/engine/resolve/response.go b/v2/pkg/engine/resolve/response.go index fb4b8db60b..2193175d24 100644 --- a/v2/pkg/engine/resolve/response.go +++ b/v2/pkg/engine/resolve/response.go @@ -109,6 +109,16 @@ type DeferFetchGroup struct { type GraphQLResponseInfo struct { OperationType ast.OperationType + // AuthorizationCoordinates lists every protected field selected by the operation, + // deduplicated by {DataSourceID, TypeName, FieldName}. It is populated once per plan by the + // postprocess package while building the fetch tree, and drives pre-fetch field authorization. + AuthorizationCoordinates []AuthorizationCoordinate +} + +// AuthorizationCoordinate is a protected field coordinate paired with the data source that resolves it. +type AuthorizationCoordinate struct { + DataSourceID string + Coordinate GraphCoordinate } type RenameTypeName struct {