Skip to content

Commit 13b75f2

Browse files
committed
feat(cache-analytics): add ForceHashAnalyticsKeys planner override
Adds a Configuration.ForceHashAnalyticsKeys planner flag. When set, the plan visitor ORs it onto the per-entity HashAnalyticsKeys setting so a true value can never be downgraded by per-entity SDL config. Use case: router operators need a guarantee that no raw entity key ever leaves the engine via analytics (PII / GDPR), regardless of what each subgraph SDL declares.
1 parent ead3ca8 commit 13b75f2

3 files changed

Lines changed: 92 additions & 1 deletion

File tree

v2/pkg/engine/plan/caching_planner_state.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,11 +880,18 @@ func (s *cachingPlannerState) entityCacheAnalytics(typeName string) *resolve.Obj
880880
// Extract full key structure from @key SelectionSets
881881
keys := fedConfig.Keys.FilterByTypeAndResolvability(typeName, true)
882882
keyFields := extractKeyFields(keys, typeName)
883-
// Get hash mode from entity cache config (default false)
883+
// Get hash mode from entity cache config (default false), then OR
884+
// with the planner's global ForceHashAnalyticsKeys flag. The global
885+
// flag exists so router operators can guarantee that no raw entity
886+
// key ever leaves the engine via analytics, regardless of what each
887+
// subgraph SDL declares.
884888
var hashKeys bool
885889
if cacheConfig := fedConfig.EntityCacheConfig(typeName); cacheConfig != nil {
886890
hashKeys = cacheConfig.HashAnalyticsKeys
887891
}
892+
if s.visitor.Config.ForceHashAnalyticsKeys {
893+
hashKeys = true
894+
}
888895
result := &resolve.ObjectCacheAnalytics{
889896
KeyFields: keyFields,
890897
HashKeys: hashKeys,

v2/pkg/engine/plan/configuration.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ type Configuration struct {
5050
// DisableEntityCaching disables planning of L2 entity caching metadata and mutation-impact logic.
5151
// L1 cache templates are still generated regardless of this setting.
5252
DisableEntityCaching bool
53+
54+
// ForceHashAnalyticsKeys, when true, forces every entity's plan-time
55+
// HashKeys flag to true regardless of the per-entity HashAnalyticsKeys
56+
// setting in the federation config. The plan visitor ORs this with the
57+
// per-entity setting; a true value here cannot be downgraded by the
58+
// per-entity flag. Use this when the analytics export pipeline must
59+
// avoid emitting raw entity keys (e.g., to satisfy PII / GDPR boundaries).
60+
// Default: false (per-entity setting controls hashing).
61+
ForceHashAnalyticsKeys bool
5362
// DisableFetchProvidesData disables planning of meta information about which fields are provided by a fetch
5463
DisableFetchProvidesData bool
5564

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package plan
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// TestVisitor_ForceHashAnalyticsKeys verifies that Configuration.ForceHashAnalyticsKeys
11+
// ORs onto the per-entity HashAnalyticsKeys setting and cannot be downgraded by the
12+
// per-entity flag. This is the core invariant the global override exists to provide:
13+
// router operators need to guarantee no raw entity key leaves the engine via analytics
14+
// (PII / GDPR), regardless of what each subgraph SDL declares.
15+
func TestVisitor_ForceHashAnalyticsKeys(t *testing.T) {
16+
cases := []struct {
17+
name string
18+
perEntityHash bool
19+
forceHash bool
20+
wantHashKeys bool
21+
}{
22+
{"both off — raw keys", false, false, false},
23+
{"per-entity on, force off — per-entity wins (true)", true, false, true},
24+
{"per-entity off, force on — force overrides", false, true, true},
25+
{"both on — true", true, true, true},
26+
}
27+
28+
for _, tc := range cases {
29+
t.Run(tc.name, func(t *testing.T) {
30+
metadata := &DataSourceMetadata{
31+
FederationMetaData: FederationMetaData{
32+
Keys: FederationFieldConfigurations{
33+
{TypeName: "User", SelectionSet: "id"},
34+
},
35+
EntityCaching: EntityCacheConfigurations{
36+
{
37+
TypeName: "User",
38+
CacheName: "default",
39+
HashAnalyticsKeys: tc.perEntityHash,
40+
},
41+
},
42+
},
43+
}
44+
ds, err := NewDataSourceConfiguration[any]("ds-1", nil, metadata, nil)
45+
require.NoError(t, err)
46+
47+
v := &Visitor{
48+
Config: Configuration{
49+
DataSources: []DataSource{ds},
50+
ForceHashAnalyticsKeys: tc.forceHash,
51+
},
52+
}
53+
caching := newCachingPlannerState(v)
54+
55+
analytics := caching.entityCacheAnalytics("User")
56+
require.NotNil(t, analytics, "User is an entity, analytics must be non-nil")
57+
assert.Equal(t, tc.wantHashKeys, analytics.HashKeys,
58+
"HashKeys = perEntity(%v) || force(%v)", tc.perEntityHash, tc.forceHash)
59+
})
60+
}
61+
62+
t.Run("non-entity returns nil regardless of force flag", func(t *testing.T) {
63+
// ForceHashAnalyticsKeys must not cause non-entities to spuriously
64+
// report as entities — it only ORs the HashKeys field on entities
65+
// that already exist in the federation config.
66+
v := &Visitor{
67+
Config: Configuration{
68+
DataSources: nil,
69+
ForceHashAnalyticsKeys: true,
70+
},
71+
}
72+
caching := newCachingPlannerState(v)
73+
assert.Nil(t, caching.entityCacheAnalytics("NotAnEntity"))
74+
})
75+
}

0 commit comments

Comments
 (0)