Skip to content

Commit 7eae091

Browse files
authored
Merge pull request #908 from fluxcd/auth-gcp
[RFC-0010] Add gcp auth library
2 parents 9f68942 + 45fbfee commit 7eae091

File tree

8 files changed

+436
-1
lines changed

8 files changed

+436
-1
lines changed

auth/gcp/gke_metadata.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package gcp
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"sync"
23+
24+
"cloud.google.com/go/compute/metadata"
25+
)
26+
27+
type gkeMetadataLoader struct {
28+
projectID string
29+
location string
30+
name string
31+
32+
mu sync.RWMutex
33+
loaded bool
34+
}
35+
36+
var gkeMetadata gkeMetadataLoader
37+
38+
func (g *gkeMetadataLoader) getAudience(ctx context.Context) (string, error) {
39+
if err := g.load(ctx); err != nil {
40+
return "", err
41+
}
42+
wiPool, _ := g.workloadIdentityPool(ctx)
43+
wiProvider, _ := g.workloadIdentityProvider(ctx)
44+
return fmt.Sprintf("identitynamespace:%s:%s", wiPool, wiProvider), nil
45+
}
46+
47+
func (g *gkeMetadataLoader) workloadIdentityPool(ctx context.Context) (string, error) {
48+
if err := g.load(ctx); err != nil {
49+
return "", err
50+
}
51+
return fmt.Sprintf("%s.svc.id.goog", g.projectID), nil
52+
}
53+
54+
func (g *gkeMetadataLoader) workloadIdentityProvider(ctx context.Context) (string, error) {
55+
if err := g.load(ctx); err != nil {
56+
return "", err
57+
}
58+
return fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s",
59+
g.projectID,
60+
g.location,
61+
g.name), nil
62+
}
63+
64+
// load loads the GKE cluster metadata from the metadata service, assuming the
65+
// pod is running on a GKE node/pod. It will fail otherwise, and this
66+
// is the reason why this method should be called lazily. If this code ran on any
67+
// other cluster that is not GKE it would fail consistently and throw the pods
68+
// in crash loop if running on startup. This method is thread-safe and will
69+
// only load the metadata successfully once.
70+
//
71+
// Technically we could receive options here to use a custom HTTP client with
72+
// a proxy, but this proxy is configured at the object level and here we are
73+
// loading cluster-level metadata that doesn't change during the lifetime of
74+
// the pod. So we can't use an object-level proxy here. Furthermore, this
75+
// implementation targets specifically GKE clusters, and in such clusters the
76+
// metadata server is usually a DaemonSet pod that serves only node-local
77+
// traffic, so a proxy doesn't make sense here anyway.
78+
func (g *gkeMetadataLoader) load(ctx context.Context) error {
79+
// Bail early if the metadata was already loaded.
80+
g.mu.RLock()
81+
loaded := g.loaded
82+
g.mu.RUnlock()
83+
if loaded {
84+
return nil
85+
}
86+
87+
g.mu.Lock()
88+
defer g.mu.Unlock()
89+
90+
// Check again if the metadata was loaded while we were waiting for the lock.
91+
if g.loaded {
92+
return nil
93+
}
94+
95+
client := metadata.NewClient(nil)
96+
97+
projectID, err := client.GetWithContext(ctx, "project/project-id")
98+
if err != nil {
99+
return fmt.Errorf("failed to get GKE cluster project ID from the metadata service: %w", err)
100+
}
101+
if projectID == "" {
102+
return fmt.Errorf("failed to get GKE cluster project ID from the metadata service: empty value")
103+
}
104+
105+
location, err := client.GetWithContext(ctx, "instance/attributes/cluster-location")
106+
if err != nil {
107+
return fmt.Errorf("failed to get GKE cluster location from the metadata service: %w", err)
108+
}
109+
if location == "" {
110+
return fmt.Errorf("failed to get GKE cluster location from the metadata service: empty value")
111+
}
112+
113+
name, err := client.GetWithContext(ctx, "instance/attributes/cluster-name")
114+
if err != nil {
115+
return fmt.Errorf("failed to get GKE cluster name from the metadata service: %w", err)
116+
}
117+
if name == "" {
118+
return fmt.Errorf("failed to get GKE cluster name from the metadata service: empty value")
119+
}
120+
121+
g.projectID = projectID
122+
g.location = location
123+
g.name = name
124+
g.loaded = true
125+
126+
return nil
127+
}

auth/gcp/options.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package gcp
18+
19+
import (
20+
"fmt"
21+
"regexp"
22+
23+
corev1 "k8s.io/api/core/v1"
24+
)
25+
26+
const serviceAccountEmailPattern = `^[a-zA-Z0-9-]{1,100}@[a-zA-Z0-9-]{1,100}\.iam\.gserviceaccount\.com$`
27+
28+
var serviceAccountEmailRegex = regexp.MustCompile(serviceAccountEmailPattern)
29+
30+
func getServiceAccountEmail(serviceAccount corev1.ServiceAccount) (string, error) {
31+
email := serviceAccount.Annotations["iam.gke.io/gcp-service-account"]
32+
if email == "" {
33+
return "", nil
34+
}
35+
if !serviceAccountEmailRegex.MatchString(email) {
36+
return "", fmt.Errorf("invalid GCP service account email: '%s'. must match %s",
37+
email, serviceAccountEmailPattern)
38+
}
39+
return email, nil
40+
}

auth/gcp/provider.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package gcp
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"golang.org/x/oauth2"
24+
"golang.org/x/oauth2/google"
25+
"golang.org/x/oauth2/google/externalaccount"
26+
corev1 "k8s.io/api/core/v1"
27+
28+
auth "github.com/fluxcd/pkg/auth"
29+
)
30+
31+
// ProviderName is the name of the GCP authentication provider.
32+
const ProviderName = "gcp"
33+
34+
var scopes = []string{
35+
"https://www.googleapis.com/auth/cloud-platform",
36+
"https://www.googleapis.com/auth/userinfo.email",
37+
}
38+
39+
// Provider implements the auth.Provider interface for GCP authentication.
40+
type Provider struct{}
41+
42+
// GetName implements auth.Provider.
43+
func (Provider) GetName() string {
44+
return ProviderName
45+
}
46+
47+
// NewDefaultToken implements auth.Provider.
48+
func (Provider) NewDefaultToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) {
49+
var o auth.Options
50+
o.Apply(opts...)
51+
52+
if hc := o.GetHTTPClient(); hc != nil {
53+
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
54+
}
55+
56+
src, err := google.DefaultTokenSource(ctx, scopes...)
57+
if err != nil {
58+
return nil, err
59+
}
60+
token, err := src.Token()
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
return &Token{*token}, nil
66+
}
67+
68+
// GetAudience implements auth.Provider.
69+
func (Provider) GetAudience(ctx context.Context) (string, error) {
70+
return gkeMetadata.workloadIdentityPool(ctx)
71+
}
72+
73+
// GetIdentity implements auth.Provider.
74+
func (Provider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
75+
email, err := getServiceAccountEmail(serviceAccount)
76+
if err != nil {
77+
return "", err
78+
}
79+
return email, nil
80+
}
81+
82+
// NewTokenForServiceAccount implements auth.Provider.
83+
func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string,
84+
serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) {
85+
86+
var o auth.Options
87+
o.Apply(opts...)
88+
89+
audience, err := gkeMetadata.getAudience(ctx)
90+
if err != nil {
91+
return nil, err
92+
}
93+
94+
conf := externalaccount.Config{
95+
UniverseDomain: "googleapis.com",
96+
Audience: audience,
97+
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
98+
TokenURL: "https://sts.googleapis.com/v1/token",
99+
SubjectTokenSupplier: tokenSupplier(oidcToken),
100+
Scopes: scopes,
101+
}
102+
103+
email, err := getServiceAccountEmail(serviceAccount)
104+
if err != nil {
105+
return nil, err
106+
}
107+
108+
if email != "" { // impersonation
109+
conf.ServiceAccountImpersonationURL = fmt.Sprintf(
110+
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken",
111+
email)
112+
} else { // direct access
113+
conf.TokenInfoURL = "https://sts.googleapis.com/v1/introspect"
114+
}
115+
116+
if hc := o.GetHTTPClient(); hc != nil {
117+
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
118+
}
119+
120+
src, err := externalaccount.NewTokenSource(ctx, conf)
121+
if err != nil {
122+
return nil, err
123+
}
124+
token, err := src.Token()
125+
if err != nil {
126+
return nil, err
127+
}
128+
129+
return &Token{*token}, nil
130+
}
131+
132+
// GetArtifactCacheKey implements auth.Provider.
133+
func (Provider) GetArtifactCacheKey(artifactRepository string) string {
134+
// The artifact repository is irrelevant for GCP registry credentials.
135+
return ProviderName
136+
}
137+
138+
// NewArtifactRegistryToken implements auth.Provider.
139+
func (Provider) NewArtifactRegistryToken(ctx context.Context, artifactRepository string,
140+
accessToken auth.Token, opts ...auth.Option) (auth.Token, error) {
141+
142+
t := accessToken.(*Token)
143+
144+
// The artifact repository is irrelevant for GCP registry credentials.
145+
return &auth.ArtifactRegistryCredentials{
146+
Username: "oauth2accesstoken",
147+
Password: t.AccessToken,
148+
ExpiresAt: t.Expiry,
149+
}, nil
150+
}

auth/gcp/token.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package gcp
18+
19+
import (
20+
"time"
21+
22+
"golang.org/x/oauth2"
23+
)
24+
25+
// Token is the GCP token.
26+
type Token struct{ oauth2.Token }
27+
28+
// GetDuration implements auth.Token.
29+
func (t *Token) GetDuration() time.Duration {
30+
return time.Until(t.Expiry)
31+
}
32+
33+
// Source gets a token source for the token to use with GCP libraries.
34+
func (t *Token) Source() oauth2.TokenSource {
35+
return oauth2.StaticTokenSource(&t.Token)
36+
}

0 commit comments

Comments
 (0)