Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ee22d6b
feat(resolve): add opt-in pre-fetch field authorization mode
jensneuse Jul 2, 2026
b36e296
fix(resolve): address Codex and CodeRabbit review feedback
jensneuse Jul 2, 2026
3f9f28e
fix(resolve): authorize subscriptions before starting the trigger
jensneuse Jul 2, 2026
944173f
fix(resolve): fail closed on unexpected batch authorizer decision count
jensneuse Jul 2, 2026
c2c2fbc
fix(resolve): avoid duplicate authorization errors after null propaga…
jensneuse Jul 2, 2026
5f49e24
docs(resolve): document authorization coordinate collection and no-do…
jensneuse Jul 2, 2026
663287a
test(plan): cover authorization coordinate collection during planning
jensneuse Jul 2, 2026
f9262cc
Merge remote-tracking branch 'origin/master' into ENG-9828-router-add…
jensneuse Jul 2, 2026
5e50ef1
Merge remote-tracking branch 'origin/master' into ENG-9828-router-add…
jensneuse Jul 3, 2026
3a1c375
move collect auth into postprocess
devsergiy Jul 6, 2026
d3d9e72
add dedicated field authorizer
devsergiy Jul 6, 2026
f6864d8
move auth methods back to resolvable to reduce the diff
devsergiy Jul 6, 2026
b710f04
add combined auth errors test
devsergiy Jul 6, 2026
d82312b
add unreached auth walk
devsergiy Jul 6, 2026
91b9358
use array wildcard
devsergiy Jul 6, 2026
b1d8e7a
cleanup
devsergiy Jul 6, 2026
1881496
cleanup
devsergiy Jul 6, 2026
fa89c63
make sure postprocess populates auth coords
devsergiy Jul 6, 2026
66ef719
Merge remote-tracking branch 'origin/master' into ENG-9828-router-add…
Noroth Jul 7, 2026
71604af
Merge branch 'master' into ENG-9828-router-add-pre-fetch-scope-author…
ysmolski Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion v2/pkg/engine/datasourcetesting/datasourcetesting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
}
}

Expand Down
132 changes: 132 additions & 0 deletions v2/pkg/engine/postprocess/collect_authorization_coordinates.go
Original file line number Diff line number Diff line change
@@ -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,
},
}
}
Loading
Loading