Skip to content

Commit d74861e

Browse files
xds/gcp_authn: implement GCP Authentication filter (gRFC A83). (#9119)
This PR implements the xDS GCP Authentication HTTP filter for gRPC-Go, as specified in [gRFC A83](https://github.com/grpc/proposal/blob/master/A83-xds-gcp-authn-filter.md). The implementation provides a mechanism for attaching service account identity JWT tokens as gRPC call credentials on GCP, featuring a two-level caching strategy (filter-level cache of credentials objects and credential-level cache of tokens) to minimize latency and metadata server load. RELEASE NOTES: * xds: add support for the GCP Authentication HTTP Filter. It automatically fetches and attaches GCP Service Account Identity JWT tokens to outgoing RPCs. This feature is currently gated behind the `GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER` environment variable.
1 parent 83728f5 commit d74861e

19 files changed

Lines changed: 1681 additions & 53 deletions

internal/xds/balancer/clustermanager/picker.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func newPickerGroup(idToPickerState map[string]*subBalancerState) *pickerGroup {
4343
}
4444

4545
func (pg *pickerGroup) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
46-
cluster := getPickedCluster(info.Ctx)
46+
cluster := PickedCluster(info.Ctx)
4747
if p := pg.pickers[cluster]; p != nil {
4848
return p.Pick(info)
4949
}
@@ -52,17 +52,12 @@ func (pg *pickerGroup) Pick(info balancer.PickInfo) (balancer.PickResult, error)
5252

5353
type clusterKey struct{}
5454

55-
func getPickedCluster(ctx context.Context) string {
55+
// PickedCluster returns the cluster name stored in the context.
56+
func PickedCluster(ctx context.Context) string {
5657
cluster, _ := ctx.Value(clusterKey{}).(string)
5758
return cluster
5859
}
5960

60-
// GetPickedClusterForTesting returns the cluster in the context; to be used
61-
// for testing only.
62-
func GetPickedClusterForTesting(ctx context.Context) string {
63-
return getPickedCluster(ctx)
64-
}
65-
6661
// SetPickedCluster adds the selected cluster to the context for the
6762
// xds_cluster_manager LB policy to pick.
6863
func SetPickedCluster(ctx context.Context, cluster string) context.Context {

internal/xds/httpfilter/extproc/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ func (s) TestBuildClientInterceptor_Success(t *testing.T) {
735735
for _, tc := range tests {
736736
t.Run(tc.name, func(t *testing.T) {
737737
builder := builder{}
738-
filter := builder.BuildClientFilter()
738+
filter := builder.BuildClientFilter(httpfilter.ClientFilterOptions{})
739739
defer filter.Close()
740740

741741
intptr, err := filter.BuildClientInterceptor(tc.cfg, tc.override)
@@ -818,7 +818,7 @@ func (s) TestBuildClientInterceptor_Failure(t *testing.T) {
818818
for _, tc := range tests {
819819
t.Run(tc.name, func(t *testing.T) {
820820
builder := builder{}
821-
filter := builder.BuildClientFilter()
821+
filter := builder.BuildClientFilter(httpfilter.ClientFilterOptions{})
822822
defer filter.Close()
823823

824824
_, err := filter.BuildClientInterceptor(tc.cfg, tc.override)

internal/xds/httpfilter/extproc/ext_proc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func (builder) IsTerminal() bool {
190190
return false
191191
}
192192

193-
func (builder) BuildClientFilter() httpfilter.ClientFilter {
193+
func (builder) BuildClientFilter(httpfilter.ClientFilterOptions) httpfilter.ClientFilter {
194194
return clientFilter{}
195195
}
196196

internal/xds/httpfilter/fault/fault.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func (builder) IsTerminal() bool {
105105
return false
106106
}
107107

108-
func (builder) BuildClientFilter() httpfilter.ClientFilter {
108+
func (builder) BuildClientFilter(httpfilter.ClientFilterOptions) httpfilter.ClientFilter {
109109
return clientFilter{}
110110
}
111111

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
/*
2+
*
3+
* Copyright 2026 gRPC authors.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
// Package gcpauthn implements the GCP Authentication HTTP filter.
20+
package gcpauthn
21+
22+
import (
23+
"container/list"
24+
"context"
25+
"fmt"
26+
"strings"
27+
"sync"
28+
29+
"golang.org/x/sync/singleflight"
30+
"google.golang.org/grpc"
31+
"google.golang.org/grpc/codes"
32+
"google.golang.org/grpc/credentials"
33+
"google.golang.org/grpc/credentials/google"
34+
"google.golang.org/grpc/internal/resolver"
35+
"google.golang.org/grpc/internal/xds/balancer/clustermanager"
36+
"google.golang.org/grpc/internal/xds/httpfilter"
37+
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource"
38+
"google.golang.org/grpc/status"
39+
"google.golang.org/protobuf/proto"
40+
41+
v3gcpauthnpb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/gcp_authn/v3"
42+
anypb "google.golang.org/protobuf/types/known/anypb"
43+
)
44+
45+
const defaultCacheSize = 10 // default capacity of the LRU credentials cache.
46+
47+
func init() {
48+
httpfilter.Register(builder{})
49+
}
50+
51+
type builder struct{}
52+
53+
type config struct {
54+
httpfilter.FilterConfig
55+
cacheSize uint64
56+
}
57+
58+
func (builder) TypeURLs() []string {
59+
return []string{"type.googleapis.com/envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig"}
60+
}
61+
62+
func (builder) ParseFilterConfig(cfg proto.Message) (httpfilter.FilterConfig, error) {
63+
m, ok := cfg.(*anypb.Any)
64+
if !ok {
65+
return nil, fmt.Errorf("gcpauthn: invalid filter config type %T", cfg)
66+
}
67+
msg := &v3gcpauthnpb.GcpAuthnFilterConfig{}
68+
if err := m.UnmarshalTo(msg); err != nil {
69+
return nil, fmt.Errorf("gcpauthn: failed to unmarshal filter config: %v", err)
70+
}
71+
72+
cacheSize := uint64(defaultCacheSize)
73+
if cacheSizeConfig := msg.GetCacheConfig().GetCacheSize(); cacheSizeConfig != nil {
74+
if cacheSize = cacheSizeConfig.GetValue(); cacheSize == 0 {
75+
return nil, fmt.Errorf("gcpauthn: cache_config.cache_size must be greater than zero")
76+
}
77+
}
78+
79+
return config{cacheSize: cacheSize}, nil
80+
}
81+
82+
// ParseFilterConfigOverride parses the provided override configuration.
83+
//
84+
// Note that we don't support overrides for this filter configuration,
85+
// but still validate it as part of the normal resource validation.
86+
func (b builder) ParseFilterConfigOverride(cfg proto.Message) (httpfilter.FilterConfig, error) {
87+
return b.ParseFilterConfig(cfg)
88+
}
89+
90+
func (builder) IsTerminal() bool {
91+
return false
92+
}
93+
94+
func (builder) BuildClientFilter(opts httpfilter.ClientFilterOptions) httpfilter.ClientFilter {
95+
ctx, cancel := context.WithCancel(context.Background())
96+
return &clientFilter{
97+
ctx: ctx,
98+
cancel: cancel,
99+
filterName: opts.FilterName,
100+
}
101+
}
102+
103+
var _ httpfilter.ClientFilterBuilder = builder{}
104+
105+
// clientFilter implements the httpfilter.ClientFilter interface.
106+
type clientFilter struct {
107+
// ctx is initialized using context.Background() and is scoped to the
108+
// lifetime of the filter. It is used as the parent context for fetching
109+
// service account identity credentials, ensuring that token fetch requests
110+
// are not terminated by individual RPC's context.
111+
ctx context.Context
112+
113+
// cancel is the cancellation function for ctx, called when the filter
114+
// is closed.
115+
cancel context.CancelFunc
116+
117+
// filterName is the name of the HTTP filter instance in the xDS
118+
// configuration. It is used as the key in the cluster metadata to look up
119+
// the audience value for the cluster to which the RPC is destined.
120+
filterName string
121+
122+
// cache is the LRU cache of PerRPCCredentials instances, keyed by audience
123+
// and is initialized or resized when BuildClientInterceptor is called.
124+
cache *lruCache
125+
}
126+
127+
// BuildClientInterceptor builds a client interceptor for the GCP
128+
// Authentication filter.
129+
func (cf *clientFilter) BuildClientInterceptor(cfg, _ httpfilter.FilterConfig) (httpfilter.ClientInterceptor, error) {
130+
c, ok := cfg.(config)
131+
if !ok {
132+
return nil, fmt.Errorf("gcpauthn: invalid filter config type %T", cfg)
133+
}
134+
135+
if cf.cache == nil {
136+
cf.cache = newLRUCache(c.cacheSize)
137+
} else {
138+
cf.cache.resizeCache(c.cacheSize)
139+
}
140+
141+
return &interceptor{
142+
ctx: cf.ctx,
143+
filterName: cf.filterName,
144+
cache: cf.cache,
145+
}, nil
146+
}
147+
148+
// Close closes the client filter.
149+
func (cf *clientFilter) Close() {
150+
cf.cancel()
151+
}
152+
153+
type interceptor struct {
154+
ctx context.Context
155+
filterName string
156+
cache *lruCache
157+
}
158+
159+
func (i *interceptor) NewStream(ctx context.Context, _ resolver.RPCInfo, newStream func(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStream, error), opts ...grpc.CallOption) (grpc.ClientStream, error) {
160+
clusterName := clustermanager.PickedCluster(ctx)
161+
// The picked cluster name in the context is formatted by the xDS
162+
// resolver with a prefix to distinguish between standard CDS clusters and
163+
// cluster_specifier_plugins:
164+
// - cluster_specifier_plugin: Since the target cluster is dynamically
165+
// resolved later at the load balancing layer, the final cluster name is
166+
// not yet known at this stage, hence we bypass this filter.
167+
// - cluster: Standard CDS routing where the destination cluster is
168+
// known at routing time. We strip the prefix to get the raw cluster name,
169+
// which is used to look up the cluster configuration in the CDS response.
170+
if strings.HasPrefix(clusterName, "cluster_specifier_plugin:") {
171+
return newStream(ctx, opts...)
172+
}
173+
clusterName = strings.TrimPrefix(clusterName, "cluster:")
174+
175+
cfg := xdsresource.XDSConfigFromContext(ctx)
176+
if cfg == nil {
177+
return nil, status.Errorf(codes.Unavailable, "gcpauthn: xDS config not found in context")
178+
}
179+
180+
clusterResult, ok := cfg.Clusters[clusterName]
181+
if !ok {
182+
return nil, status.Errorf(codes.Unavailable, "gcpauthn: cluster %q not found in xDS config", clusterName)
183+
}
184+
185+
if clusterResult.Err != nil {
186+
return nil, status.Errorf(codes.Unavailable, "gcpauthn: cluster config for %q is invalid or missing: %v", clusterName, clusterResult.Err)
187+
}
188+
189+
val, ok := clusterResult.Config.Cluster.Metadata[i.filterName]
190+
if !ok {
191+
return newStream(ctx, opts...)
192+
}
193+
194+
audienceMetadata, ok := val.(xdsresource.AudienceMetadataValue)
195+
if !ok {
196+
return nil, status.Errorf(codes.Unavailable, "gcpauthn: cluster metadata for key %q is not of type AudienceMetadataValue, got %T", i.filterName, val)
197+
}
198+
199+
creds, err := i.cache.getOrCreate(i.ctx, audienceMetadata.Audience)
200+
if err != nil {
201+
return nil, status.Errorf(codes.Unavailable, "gcpauthn: failed to create credentials: %v", err)
202+
}
203+
204+
// We pass the credentials via a PerRPCCredentials call option rather than
205+
// directly attaching the token here. Since this filter runs before load
206+
// balancer has selected a connection, it cannot check if the connection is
207+
// secure. Passing it as a call option defers token retrieval and injection
208+
// to the credentials package, which can verify transport security after
209+
// the connection has been established.
210+
opts = append(opts, grpc.PerRPCCredentials(creds))
211+
212+
return newStream(ctx, opts...)
213+
}
214+
215+
func (i *interceptor) Close() {}
216+
217+
// cacheEntry represents a cached PerRPCCredentials instance and its position
218+
// in the LRU list.
219+
type cacheEntry struct {
220+
creds credentials.PerRPCCredentials
221+
elem *list.Element
222+
}
223+
224+
// lruCache is a thread-safe LRU cache that stores PerRPCCredentials instances
225+
// by their target audience string.
226+
type lruCache struct {
227+
// The following fields are protected by mu.
228+
mu sync.Mutex
229+
230+
// cacheSize is the maximum capacity of the lruList.
231+
cacheSize uint64
232+
233+
// lruList is a doubly linked list tracking access order. The front of the
234+
// list holds the most recently used entry, and the back holds the least
235+
// recently used entry. Elements in the list store values of type
236+
// string (representing the target audience).
237+
lruList *list.List
238+
239+
// cache maps audience keys to their corresponding cacheEntry pointers,
240+
// allowing O(1) lookups and updates in the LRU list.
241+
cache map[string]*cacheEntry
242+
243+
// sf is used to deduplicate credential creation for the same audience.
244+
sf singleflight.Group
245+
}
246+
247+
// newLRUCache instantiates a new lruCache with the specified capacity.
248+
func newLRUCache(size uint64) *lruCache {
249+
return &lruCache{
250+
cacheSize: size,
251+
lruList: list.New(),
252+
cache: make(map[string]*cacheEntry),
253+
}
254+
}
255+
256+
// resizeCache dynamically updates the capacity of the LRU cache,
257+
// immediately evicting Least Recently Used entries if the new size is
258+
// smaller than the current cache size.
259+
func (c *lruCache) resizeCache(newCacheSize uint64) {
260+
c.mu.Lock()
261+
defer c.mu.Unlock()
262+
263+
if c.cacheSize == newCacheSize {
264+
return
265+
}
266+
267+
c.cacheSize = newCacheSize
268+
for uint64(len(c.cache)) > c.cacheSize {
269+
c.removeOldestLocked()
270+
}
271+
}
272+
273+
// getOrCreate retrieves or constructs the PerRPCCredentials for a specified
274+
// audience. If the audience is not found in the cache, it creates new
275+
// credentials using the configured creator, adds it to the cache, and evicts
276+
// the least recently used entry if the cache size is at capacity.
277+
func (c *lruCache) getOrCreate(ctx context.Context, audience string) (credentials.PerRPCCredentials, error) {
278+
if creds, ok := c.getExisting(audience); ok {
279+
return creds, nil
280+
}
281+
282+
val, err, _ := c.sf.Do(audience, func() (any, error) {
283+
// Double-check cache inside singleflight callback in case another call
284+
// completed the initial check before the current call updates the cache.
285+
if creds, ok := c.getExisting(audience); ok {
286+
return creds, nil
287+
}
288+
289+
creds, err := google.NewServiceAccountIdentityCredentials(ctx, audience)
290+
if err != nil {
291+
return nil, err
292+
}
293+
294+
c.mu.Lock()
295+
defer c.mu.Unlock()
296+
297+
if uint64(len(c.cache)) >= c.cacheSize {
298+
c.removeOldestLocked()
299+
}
300+
c.cache[audience] = &cacheEntry{
301+
creds: creds,
302+
elem: c.lruList.PushFront(audience),
303+
}
304+
return creds, nil
305+
})
306+
if err != nil {
307+
return nil, err
308+
}
309+
return val.(credentials.PerRPCCredentials), nil
310+
}
311+
312+
// getExisting retrieves the PerRPCCredentials for a specified audience if it
313+
// already exists in the cache and updates the access order in the LRU list.
314+
//
315+
// The boolean return value indicates if an entry was found in the cache.
316+
func (c *lruCache) getExisting(audience string) (credentials.PerRPCCredentials, bool) {
317+
c.mu.Lock()
318+
defer c.mu.Unlock()
319+
320+
if entry, ok := c.cache[audience]; ok {
321+
c.lruList.MoveToFront(entry.elem)
322+
return entry.creds, ok
323+
}
324+
return nil, false
325+
}
326+
327+
// removeOldestLocked evicts the least recently used entry from the cache.
328+
// It must be called with mu locked.
329+
func (c *lruCache) removeOldestLocked() {
330+
oldest := c.lruList.Back()
331+
delete(c.cache, oldest.Value.(string))
332+
c.lruList.Remove(oldest)
333+
}

0 commit comments

Comments
 (0)