From 45fbfeeab8d7e2b70f1e7b0cd26087de440790cb Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Tue, 15 Apr 2025 02:33:37 +0100 Subject: [PATCH] [RFC-0010] Add gcp auth library Signed-off-by: Matheus Pimenta --- auth/gcp/gke_metadata.go | 127 +++++++++++++++++++++++++++++++ auth/gcp/options.go | 40 ++++++++++ auth/gcp/provider.go | 150 +++++++++++++++++++++++++++++++++++++ auth/gcp/token.go | 36 +++++++++ auth/gcp/token_source.go | 49 ++++++++++++ auth/gcp/token_supplier.go | 30 ++++++++ auth/go.mod | 3 +- auth/go.sum | 2 + 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 auth/gcp/gke_metadata.go create mode 100644 auth/gcp/options.go create mode 100644 auth/gcp/provider.go create mode 100644 auth/gcp/token.go create mode 100644 auth/gcp/token_source.go create mode 100644 auth/gcp/token_supplier.go diff --git a/auth/gcp/gke_metadata.go b/auth/gcp/gke_metadata.go new file mode 100644 index 000000000..33239b436 --- /dev/null +++ b/auth/gcp/gke_metadata.go @@ -0,0 +1,127 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "context" + "fmt" + "sync" + + "cloud.google.com/go/compute/metadata" +) + +type gkeMetadataLoader struct { + projectID string + location string + name string + + mu sync.RWMutex + loaded bool +} + +var gkeMetadata gkeMetadataLoader + +func (g *gkeMetadataLoader) getAudience(ctx context.Context) (string, error) { + if err := g.load(ctx); err != nil { + return "", err + } + wiPool, _ := g.workloadIdentityPool(ctx) + wiProvider, _ := g.workloadIdentityProvider(ctx) + return fmt.Sprintf("identitynamespace:%s:%s", wiPool, wiProvider), nil +} + +func (g *gkeMetadataLoader) workloadIdentityPool(ctx context.Context) (string, error) { + if err := g.load(ctx); err != nil { + return "", err + } + return fmt.Sprintf("%s.svc.id.goog", g.projectID), nil +} + +func (g *gkeMetadataLoader) workloadIdentityProvider(ctx context.Context) (string, error) { + if err := g.load(ctx); err != nil { + return "", err + } + return fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s", + g.projectID, + g.location, + g.name), nil +} + +// load loads the GKE cluster metadata from the metadata service, assuming the +// pod is running on a GKE node/pod. It will fail otherwise, and this +// is the reason why this method should be called lazily. If this code ran on any +// other cluster that is not GKE it would fail consistently and throw the pods +// in crash loop if running on startup. This method is thread-safe and will +// only load the metadata successfully once. +// +// Technically we could receive options here to use a custom HTTP client with +// a proxy, but this proxy is configured at the object level and here we are +// loading cluster-level metadata that doesn't change during the lifetime of +// the pod. So we can't use an object-level proxy here. Furthermore, this +// implementation targets specifically GKE clusters, and in such clusters the +// metadata server is usually a DaemonSet pod that serves only node-local +// traffic, so a proxy doesn't make sense here anyway. +func (g *gkeMetadataLoader) load(ctx context.Context) error { + // Bail early if the metadata was already loaded. + g.mu.RLock() + loaded := g.loaded + g.mu.RUnlock() + if loaded { + return nil + } + + g.mu.Lock() + defer g.mu.Unlock() + + // Check again if the metadata was loaded while we were waiting for the lock. + if g.loaded { + return nil + } + + client := metadata.NewClient(nil) + + projectID, err := client.GetWithContext(ctx, "project/project-id") + if err != nil { + return fmt.Errorf("failed to get GKE cluster project ID from the metadata service: %w", err) + } + if projectID == "" { + return fmt.Errorf("failed to get GKE cluster project ID from the metadata service: empty value") + } + + location, err := client.GetWithContext(ctx, "instance/attributes/cluster-location") + if err != nil { + return fmt.Errorf("failed to get GKE cluster location from the metadata service: %w", err) + } + if location == "" { + return fmt.Errorf("failed to get GKE cluster location from the metadata service: empty value") + } + + name, err := client.GetWithContext(ctx, "instance/attributes/cluster-name") + if err != nil { + return fmt.Errorf("failed to get GKE cluster name from the metadata service: %w", err) + } + if name == "" { + return fmt.Errorf("failed to get GKE cluster name from the metadata service: empty value") + } + + g.projectID = projectID + g.location = location + g.name = name + g.loaded = true + + return nil +} diff --git a/auth/gcp/options.go b/auth/gcp/options.go new file mode 100644 index 000000000..2ac09c1fb --- /dev/null +++ b/auth/gcp/options.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "fmt" + "regexp" + + corev1 "k8s.io/api/core/v1" +) + +const serviceAccountEmailPattern = `^[a-zA-Z0-9-]{1,100}@[a-zA-Z0-9-]{1,100}\.iam\.gserviceaccount\.com$` + +var serviceAccountEmailRegex = regexp.MustCompile(serviceAccountEmailPattern) + +func getServiceAccountEmail(serviceAccount corev1.ServiceAccount) (string, error) { + email := serviceAccount.Annotations["iam.gke.io/gcp-service-account"] + if email == "" { + return "", nil + } + if !serviceAccountEmailRegex.MatchString(email) { + return "", fmt.Errorf("invalid GCP service account email: '%s'. must match %s", + email, serviceAccountEmailPattern) + } + return email, nil +} diff --git a/auth/gcp/provider.go b/auth/gcp/provider.go new file mode 100644 index 000000000..53989ec15 --- /dev/null +++ b/auth/gcp/provider.go @@ -0,0 +1,150 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "context" + "fmt" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/google/externalaccount" + corev1 "k8s.io/api/core/v1" + + auth "github.com/fluxcd/pkg/auth" +) + +// ProviderName is the name of the GCP authentication provider. +const ProviderName = "gcp" + +var scopes = []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", +} + +// Provider implements the auth.Provider interface for GCP authentication. +type Provider struct{} + +// GetName implements auth.Provider. +func (Provider) GetName() string { + return ProviderName +} + +// NewDefaultToken implements auth.Provider. +func (Provider) NewDefaultToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { + var o auth.Options + o.Apply(opts...) + + if hc := o.GetHTTPClient(); hc != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, hc) + } + + src, err := google.DefaultTokenSource(ctx, scopes...) + if err != nil { + return nil, err + } + token, err := src.Token() + if err != nil { + return nil, err + } + + return &Token{*token}, nil +} + +// GetAudience implements auth.Provider. +func (Provider) GetAudience(ctx context.Context) (string, error) { + return gkeMetadata.workloadIdentityPool(ctx) +} + +// GetIdentity implements auth.Provider. +func (Provider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) { + email, err := getServiceAccountEmail(serviceAccount) + if err != nil { + return "", err + } + return email, nil +} + +// NewTokenForServiceAccount implements auth.Provider. +func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, + serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) { + + var o auth.Options + o.Apply(opts...) + + audience, err := gkeMetadata.getAudience(ctx) + if err != nil { + return nil, err + } + + conf := externalaccount.Config{ + UniverseDomain: "googleapis.com", + Audience: audience, + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + TokenURL: "https://sts.googleapis.com/v1/token", + SubjectTokenSupplier: tokenSupplier(oidcToken), + Scopes: scopes, + } + + email, err := getServiceAccountEmail(serviceAccount) + if err != nil { + return nil, err + } + + if email != "" { // impersonation + conf.ServiceAccountImpersonationURL = fmt.Sprintf( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", + email) + } else { // direct access + conf.TokenInfoURL = "https://sts.googleapis.com/v1/introspect" + } + + if hc := o.GetHTTPClient(); hc != nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, hc) + } + + src, err := externalaccount.NewTokenSource(ctx, conf) + if err != nil { + return nil, err + } + token, err := src.Token() + if err != nil { + return nil, err + } + + return &Token{*token}, nil +} + +// GetArtifactCacheKey implements auth.Provider. +func (Provider) GetArtifactCacheKey(artifactRepository string) string { + // The artifact repository is irrelevant for GCP registry credentials. + return ProviderName +} + +// NewArtifactRegistryToken implements auth.Provider. +func (Provider) NewArtifactRegistryToken(ctx context.Context, artifactRepository string, + accessToken auth.Token, opts ...auth.Option) (auth.Token, error) { + + t := accessToken.(*Token) + + // The artifact repository is irrelevant for GCP registry credentials. + return &auth.ArtifactRegistryCredentials{ + Username: "oauth2accesstoken", + Password: t.AccessToken, + ExpiresAt: t.Expiry, + }, nil +} diff --git a/auth/gcp/token.go b/auth/gcp/token.go new file mode 100644 index 000000000..6dd448319 --- /dev/null +++ b/auth/gcp/token.go @@ -0,0 +1,36 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "time" + + "golang.org/x/oauth2" +) + +// Token is the GCP token. +type Token struct{ oauth2.Token } + +// GetDuration implements auth.Token. +func (t *Token) GetDuration() time.Duration { + return time.Until(t.Expiry) +} + +// Source gets a token source for the token to use with GCP libraries. +func (t *Token) Source() oauth2.TokenSource { + return oauth2.StaticTokenSource(&t.Token) +} diff --git a/auth/gcp/token_source.go b/auth/gcp/token_source.go new file mode 100644 index 000000000..7bc98a524 --- /dev/null +++ b/auth/gcp/token_source.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "context" + "fmt" + + "golang.org/x/oauth2" + + auth "github.com/fluxcd/pkg/auth" +) + +type tokenSource struct { + ctx context.Context + opts []auth.Option +} + +// NewTokenSource creates a new token source for the given context and options. +func NewTokenSource(ctx context.Context, opts ...auth.Option) oauth2.TokenSource { + return &tokenSource{ctx, opts} +} + +// Token implements oauth2.TokenSource. +func (t *tokenSource) Token() (*oauth2.Token, error) { + token, err := auth.GetToken(t.ctx, Provider{}, t.opts...) + if err != nil { + return nil, err + } + gcpToken, ok := token.(*Token) + if !ok { + return nil, fmt.Errorf("failed to cast token to GCP token: %T", token) + } + return &gcpToken.Token, nil +} diff --git a/auth/gcp/token_supplier.go b/auth/gcp/token_supplier.go new file mode 100644 index 000000000..d427f4bac --- /dev/null +++ b/auth/gcp/token_supplier.go @@ -0,0 +1,30 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gcp + +import ( + "context" + + "golang.org/x/oauth2/google/externalaccount" +) + +type tokenSupplier string + +// SubjectToken implements externalaccount.SubjectTokenSupplier. +func (s tokenSupplier) SubjectToken(context.Context, externalaccount.SupplierOptions) (string, error) { + return string(s), nil +} diff --git a/auth/go.mod b/auth/go.mod index cf1c5cc7e..be4e1518e 100644 --- a/auth/go.mod +++ b/auth/go.mod @@ -8,6 +8,7 @@ replace ( ) require ( + cloud.google.com/go/compute/metadata v0.3.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 github.com/aws/aws-sdk-go-v2 v1.36.3 @@ -19,6 +20,7 @@ require ( github.com/fluxcd/pkg/cache v0.8.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/onsi/gomega v1.37.0 + golang.org/x/oauth2 v0.28.0 k8s.io/api v0.33.0 k8s.io/apimachinery v0.33.0 sigs.k8s.io/controller-runtime v0.20.4 @@ -69,7 +71,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect diff --git a/auth/go.sum b/auth/go.sum index d26d5146a..8a02bf4c1 100644 --- a/auth/go.sum +++ b/auth/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g=