diff --git a/apis/cluster/v1alpha1/providerconfig_types.go b/apis/cluster/v1alpha1/providerconfig_types.go index 9d12221..ab30ba5 100644 --- a/apis/cluster/v1alpha1/providerconfig_types.go +++ b/apis/cluster/v1alpha1/providerconfig_types.go @@ -35,6 +35,13 @@ type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` + // Identity used to authenticate outgoing requests. The identity + // credentials supplement 'credentials' by configuring a bearer token + // source such as OAuth. A request that carries its own Authorization + // header keeps that value. + // +optional + Identity *common.Identity `json:"identity,omitempty"` + // TLS configuration for HTTPS requests. // +optional TLS *common.TLSConfig `json:"tls,omitempty"` diff --git a/apis/cluster/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/v1alpha1/zz_generated.deepcopy.go index 80520dd..4d4e310 100644 --- a/apis/cluster/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/v1alpha1/zz_generated.deepcopy.go @@ -88,6 +88,11 @@ func (in *ProviderConfigList) DeepCopyObject() runtime.Object { func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = *in in.Credentials.DeepCopyInto(&out.Credentials) + if in.Identity != nil { + in, out := &in.Identity, &out.Identity + *out = new(common.Identity) + (*in).DeepCopyInto(*out) + } if in.TLS != nil { in, out := &in.TLS, &out.TLS *out = new(common.TLSConfig) diff --git a/apis/common/identity.go b/apis/common/identity.go new file mode 100644 index 0000000..3409e6f --- /dev/null +++ b/apis/common/identity.go @@ -0,0 +1,54 @@ +/* +Copyright 2023 The Crossplane 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 common + +import ( + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +// IdentityType used to obtain a bearer token for outgoing requests. +// +kubebuilder:validation:Enum=GoogleApplicationCredentials +type IdentityType string + +// Supported identity types. +const ( + // IdentityTypeGoogleApplicationCredentials authenticates using Google + // Application Credentials, exchanging them for an OAuth2 access token. + IdentityTypeGoogleApplicationCredentials = "GoogleApplicationCredentials" +) + +// IdentityCredentials required to obtain a token. +type IdentityCredentials struct { + // Source of the identity credentials. Use InjectedIdentity to resolve + // credentials from the provider pod's environment, for example through + // Workload Identity on GKE. + // +kubebuilder:validation:Enum=Secret;InjectedIdentity;Environment;Filesystem + Source xpv2.CredentialsSource `json:"source"` + + xpv2.CommonCredentialSelectors `json:",inline"` +} + +// Identity used to authenticate outgoing requests. +type Identity struct { + // Type of identity. + Type IdentityType `json:"type"` + + IdentityCredentials `json:",inline"` + + // Scopes requested for the access token. Defaults to + // https://www.googleapis.com/auth/cloud-platform for + // GoogleApplicationCredentials. + // +optional + Scopes []string `json:"scopes,omitempty"` +} diff --git a/apis/common/zz_generated.deepcopy.go b/apis/common/zz_generated.deepcopy.go index 63dac81..6b437ee 100644 --- a/apis/common/zz_generated.deepcopy.go +++ b/apis/common/zz_generated.deepcopy.go @@ -24,6 +24,43 @@ import ( "github.com/crossplane/crossplane/apis/v2/core/v2" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Identity) DeepCopyInto(out *Identity) { + *out = *in + in.IdentityCredentials.DeepCopyInto(&out.IdentityCredentials) + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Identity. +func (in *Identity) DeepCopy() *Identity { + if in == nil { + return nil + } + out := new(Identity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IdentityCredentials) DeepCopyInto(out *IdentityCredentials) { + *out = *in + in.CommonCredentialSelectors.DeepCopyInto(&out.CommonCredentialSelectors) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IdentityCredentials. +func (in *IdentityCredentials) DeepCopy() *IdentityCredentials { + if in == nil { + return nil + } + out := new(IdentityCredentials) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KeyInjection) DeepCopyInto(out *KeyInjection) { *out = *in diff --git a/apis/namespaced/v1alpha2/providerconfig_types.go b/apis/namespaced/v1alpha2/providerconfig_types.go index cbf83b6..5d626c4 100644 --- a/apis/namespaced/v1alpha2/providerconfig_types.go +++ b/apis/namespaced/v1alpha2/providerconfig_types.go @@ -35,6 +35,13 @@ type ProviderConfigSpec struct { // Credentials required to authenticate to this provider. Credentials ProviderCredentials `json:"credentials"` + // Identity used to authenticate outgoing requests. The identity + // credentials supplement 'credentials' by configuring a bearer token + // source such as OAuth. A request that carries its own Authorization + // header keeps that value. + // +optional + Identity *common.Identity `json:"identity,omitempty"` + // TLS configuration for HTTPS requests. // +optional TLS *common.TLSConfig `json:"tls,omitempty"` diff --git a/apis/namespaced/v1alpha2/zz_generated.deepcopy.go b/apis/namespaced/v1alpha2/zz_generated.deepcopy.go index fde0c37..6fc3b8b 100644 --- a/apis/namespaced/v1alpha2/zz_generated.deepcopy.go +++ b/apis/namespaced/v1alpha2/zz_generated.deepcopy.go @@ -205,6 +205,11 @@ func (in *ProviderConfigList) DeepCopyObject() runtime.Object { func (in *ProviderConfigSpec) DeepCopyInto(out *ProviderConfigSpec) { *out = *in in.Credentials.DeepCopyInto(&out.Credentials) + if in.Identity != nil { + in, out := &in.Identity, &out.Identity + *out = new(common.Identity) + (*in).DeepCopyInto(*out) + } if in.TLS != nil { in, out := &in.TLS, &out.TLS *out = new(common.TLSConfig) diff --git a/examples/provider/identity-config.yaml b/examples/provider/identity-config.yaml new file mode 100644 index 0000000..ba914b9 --- /dev/null +++ b/examples/provider/identity-config.yaml @@ -0,0 +1,68 @@ +--- +# ProviderConfig that authenticates outgoing requests with Google Application +# Credentials held in a Secret. The provider exchanges the service account key +# for an OAuth2 access token and sends it as a bearer token. +apiVersion: http.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: google-identity-from-secret +spec: + credentials: + source: None + identity: + type: GoogleApplicationCredentials + source: Secret + secretRef: + name: gcp-credentials + namespace: crossplane-system + key: credentials.json +--- +# ProviderConfig that resolves Google credentials from the provider pod's +# environment. On GKE this means Workload Identity; no key material is stored +# in the cluster. +apiVersion: http.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: google-identity-injected +spec: + credentials: + source: None + identity: + type: GoogleApplicationCredentials + source: InjectedIdentity +--- +# ProviderConfig requesting a narrower scope than the default +# https://www.googleapis.com/auth/cloud-platform. +apiVersion: http.crossplane.io/v1alpha1 +kind: ProviderConfig +metadata: + name: google-identity-scoped +spec: + credentials: + source: None + identity: + type: GoogleApplicationCredentials + source: Secret + secretRef: + name: gcp-credentials + namespace: crossplane-system + key: credentials.json + scopes: + - https://www.googleapis.com/auth/compute +--- +# Namespaced ProviderConfig with the same identity configuration. +apiVersion: http.m.crossplane.io/v1alpha2 +kind: ProviderConfig +metadata: + name: google-identity-from-secret + namespace: default +spec: + credentials: + source: None + identity: + type: GoogleApplicationCredentials + source: Secret + secretRef: + name: gcp-credentials + namespace: default + key: credentials.json diff --git a/go.mod b/go.mod index 08e278a..29e3633 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/go-openapi/swag/cmdutils v0.25.5 // indirect github.com/go-openapi/swag/conv v0.25.5 // indirect github.com/go-openapi/swag/fileutils v0.25.5 // indirect @@ -81,7 +82,7 @@ require ( golang.org/x/exp v0.0.0-20260112195511-716be5621a96 golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect diff --git a/go.sum b/go.sum index b166d98..90f0469 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= diff --git a/internal/clients/http/client.go b/internal/clients/http/client.go index 4453899..8efccf2 100644 --- a/internal/clients/http/client.go +++ b/internal/clients/http/client.go @@ -13,6 +13,7 @@ import ( "github.com/crossplane-contrib/provider-http/apis/interfaces" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "golang.org/x/oauth2" ) const ( @@ -40,6 +41,18 @@ type client struct { log logging.Logger timeout time.Duration authorizationToken string + tokenSource oauth2.TokenSource +} + +// Option configures a Client. +type Option func(*client) + +// WithTokenSource sets the OAuth2 token source used to authenticate requests +// that do not carry their own Authorization header. +func WithTokenSource(ts oauth2.TokenSource) Option { + return func(c *client) { + c.tokenSource = ts + } } type HttpResponse struct { @@ -128,12 +141,20 @@ func (hc *client) SendRequest(ctx context.Context, method string, url string, bo }, fmt.Errorf("failed to build TLS config: %w", err) } + var transport http.RoundTripper = &http.Transport{ + TLSClientConfig: tlsConfig, + Proxy: http.ProxyFromEnvironment, // Use proxy settings from environment + } + + // Authenticate through the token source unless the request already carries + // its own Authorization header. + if _, exists := request.Header[authKey]; !exists && hc.tokenSource != nil { + transport = &oauth2.Transport{Source: hc.tokenSource, Base: transport} + } + client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConfig, - Proxy: http.ProxyFromEnvironment, // Use proxy settings from environment - }, - Timeout: hc.timeout, + Transport: transport, + Timeout: hc.timeout, } response, err := client.Do(request) @@ -172,12 +193,18 @@ func (hc *client) SendRequest(ctx context.Context, method string, url string, bo } // NewClient returns a new Http Client -func NewClient(log logging.Logger, timeout time.Duration, authorizationToken string) (Client, error) { - return &client{ +func NewClient(log logging.Logger, timeout time.Duration, authorizationToken string, opts ...Option) (Client, error) { + c := &client{ log: log, timeout: timeout, authorizationToken: authorizationToken, - }, nil + } + + for _, opt := range opts { + opt(c) + } + + return c, nil } // toJSON converts the request to a JSON string. diff --git a/internal/clients/http/identity_loader.go b/internal/clients/http/identity_loader.go new file mode 100644 index 0000000..084a031 --- /dev/null +++ b/internal/clients/http/identity_loader.go @@ -0,0 +1,93 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/crossplane-contrib/provider-http/apis/common" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + kube "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultGoogleScopes are requested when an identity configures no scopes. +var DefaultGoogleScopes = []string{"https://www.googleapis.com/auth/cloud-platform"} + +// LoadIdentityTokenSource builds an OAuth2 token source from the identity +// configured on a ProviderConfig. It returns a nil token source when no +// identity is configured, in which case requests are sent unauthenticated +// unless they carry their own Authorization header. +func LoadIdentityTokenSource(ctx context.Context, kubeClient kube.Client, identity *common.Identity) (oauth2.TokenSource, error) { + if identity == nil { + return nil, nil + } + + switch identity.Type { + case common.IdentityTypeGoogleApplicationCredentials: + return googleTokenSource(ctx, kubeClient, identity) + default: + return nil, fmt.Errorf("unsupported identity type %q", identity.Type) + } +} + +// googleTokenSource resolves Google Application Credentials into a token +// source. Credentials in JSON format are exchanged for access tokens; a +// non-JSON value is treated as an access token itself. When the source is +// InjectedIdentity the token is resolved from the provider pod's environment, +// which on GKE means Workload Identity. +func googleTokenSource(ctx context.Context, kubeClient kube.Client, identity *common.Identity) (oauth2.TokenSource, error) { + scopes := identity.Scopes + if len(scopes) == 0 { + scopes = DefaultGoogleScopes + } + + if identity.Source == xpv2.CredentialsSourceInjectedIdentity { + ts, err := google.DefaultTokenSource(ctx, scopes...) + if err != nil { + return nil, fmt.Errorf("cannot resolve default Google credentials: %w", err) + } + return oauth2.ReuseTokenSource(nil, ts), nil + } + + credentials, err := loadIdentityCredentials(ctx, kubeClient, identity) + if err != nil { + return nil, err + } + + if !isJSON(credentials) { + token := &oauth2.Token{AccessToken: string(credentials)} + if !token.Valid() { + return nil, fmt.Errorf("google identity credentials are neither valid JSON nor a valid access token") + } + return oauth2.StaticTokenSource(token), nil + } + + creds, err := google.CredentialsFromJSON(ctx, credentials, scopes...) //nolint:staticcheck // SA1019: credentials come from a Secret we already trust; no drop-in replacement yet + if err != nil { + return nil, fmt.Errorf("cannot load Google Application Credentials from JSON: %w", err) + } + + return creds.TokenSource, nil +} + +// loadIdentityCredentials reads the identity credentials from its configured +// source. +func loadIdentityCredentials(ctx context.Context, kubeClient kube.Client, identity *common.Identity) ([]byte, error) { + if identity.Source != xpv2.CredentialsSourceSecret { + return nil, fmt.Errorf("unsupported identity credentials source %q", identity.Source) + } + + if identity.SecretRef == nil { + return nil, fmt.Errorf("identity credentials source is Secret but no secretRef is set") + } + + return loadSecretData(ctx, kubeClient, identity.SecretRef) +} + +// isJSON reports whether b is valid JSON. +func isJSON(b []byte) bool { + var js json.RawMessage + return json.Unmarshal(b, &js) == nil +} diff --git a/internal/clients/http/identity_loader_test.go b/internal/clients/http/identity_loader_test.go new file mode 100644 index 0000000..d789ba4 --- /dev/null +++ b/internal/clients/http/identity_loader_test.go @@ -0,0 +1,136 @@ +package http + +import ( + "context" + "testing" + + "github.com/crossplane-contrib/provider-http/apis/common" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kube "sigs.k8s.io/controller-runtime/pkg/client" +) + +// secretWith returns a kube client serving a single secret key. +func secretWith(name, namespace, key string, value []byte) kube.Client { + return &test.MockClient{ + MockGet: func(ctx context.Context, k kube.ObjectKey, obj kube.Object) error { + secret, ok := obj.(*corev1.Secret) + if !ok { + return errors.New("unexpected object type") + } + *secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Data: map[string][]byte{key: value}, + } + return nil + }, + } +} + +func TestLoadIdentityTokenSource(t *testing.T) { + secretRef := &xpv2.SecretKeySelector{ + SecretReference: xpv2.SecretReference{Name: "gcp-credentials", Namespace: "crossplane-system"}, + Key: "credentials.json", + } + + // A syntactically valid service account key. The private key is a throwaway + // generated for this test and is not usable against Google's API. + saKey := []byte(`{ + "type": "service_account", + "project_id": "example", + "private_key_id": "0", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAyE0hZ4pAsFCFdCF+\nJVMLAyLhQKHM5vLZ0eFCsyPQEmVKcbhYUZQvXCzuHrCoQfhVMJlHKzJPnrPqvxHF\nOZTPGwIDAQABAkA7VfDTQIf1nRQ4tG3nQEGZUCJZ2xLwXK4V6LqBQPWJ2Xy0aQzX\nnJRVpQKJZQ0Jg9HXqRhbxcRLZgVHnKKZAiEA8kLQ0hRpJZQKZ7Zx0hRVFQnFVQVF\nJZQKZ7Zx0hRVFQ0CIQDTQVFJZQKZ7Zx0hRVFQnFVQVFJZQKZ7Zx0hRVFQnFVQIhAK\n-----END PRIVATE KEY-----\n", + "client_email": "example@example.iam.gserviceaccount.com", + "client_id": "0", + "token_uri": "https://oauth2.googleapis.com/token" +}`) + + cases := map[string]struct { + kubeClient kube.Client + identity *common.Identity + wantNil bool + wantErr bool + }{ + "NoIdentity": { + identity: nil, + wantNil: true, + }, + "UnsupportedType": { + identity: &common.Identity{Type: "SomethingElse"}, + wantErr: true, + }, + "SecretSourceWithoutRef": { + identity: &common.Identity{ + Type: common.IdentityTypeGoogleApplicationCredentials, + IdentityCredentials: common.IdentityCredentials{Source: xpv2.CredentialsSourceSecret}, + }, + wantErr: true, + }, + "UnsupportedSource": { + identity: &common.Identity{ + Type: common.IdentityTypeGoogleApplicationCredentials, + IdentityCredentials: common.IdentityCredentials{Source: xpv2.CredentialsSourceNone}, + }, + wantErr: true, + }, + "AccessTokenFromSecret": { + kubeClient: secretWith("gcp-credentials", "crossplane-system", "credentials.json", []byte("ya29.an-access-token")), + identity: &common.Identity{ + Type: common.IdentityTypeGoogleApplicationCredentials, + IdentityCredentials: common.IdentityCredentials{ + Source: xpv2.CredentialsSourceSecret, + CommonCredentialSelectors: xpv2.CommonCredentialSelectors{SecretRef: secretRef}, + }, + }, + }, + "ServiceAccountKeyFromSecret": { + kubeClient: secretWith("gcp-credentials", "crossplane-system", "credentials.json", saKey), + identity: &common.Identity{ + Type: common.IdentityTypeGoogleApplicationCredentials, + IdentityCredentials: common.IdentityCredentials{ + Source: xpv2.CredentialsSourceSecret, + CommonCredentialSelectors: xpv2.CommonCredentialSelectors{SecretRef: secretRef}, + }, + }, + }, + "EmptyCredentialsRejected": { + kubeClient: secretWith("gcp-credentials", "crossplane-system", "credentials.json", []byte("")), + identity: &common.Identity{ + Type: common.IdentityTypeGoogleApplicationCredentials, + IdentityCredentials: common.IdentityCredentials{ + Source: xpv2.CredentialsSourceSecret, + CommonCredentialSelectors: xpv2.CommonCredentialSelectors{SecretRef: secretRef}, + }, + }, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ts, err := LoadIdentityTokenSource(context.Background(), tc.kubeClient, tc.identity) + + if tc.wantErr { + if err == nil { + t.Fatal("LoadIdentityTokenSource(...): expected an error, got none") + } + return + } + + if err != nil { + t.Fatalf("LoadIdentityTokenSource(...): unexpected error: %v", err) + } + + if tc.wantNil && ts != nil { + t.Fatal("LoadIdentityTokenSource(...): expected a nil token source") + } + + if !tc.wantNil && ts == nil { + t.Fatal("LoadIdentityTokenSource(...): expected a token source, got nil") + } + }) + } +} diff --git a/internal/controller/cluster/disposablerequest/disposablerequest.go b/internal/controller/cluster/disposablerequest/disposablerequest.go index 7bec539..6145847 100644 --- a/internal/controller/cluster/disposablerequest/disposablerequest.go +++ b/internal/controller/cluster/disposablerequest/disposablerequest.go @@ -51,6 +51,7 @@ const ( errNotDisposableRequest = "managed resource is not a DisposableRequest custom resource" errTrackPCUsage = "cannot track ProviderConfig usage" errNewHttpClient = "cannot create new Http client" + errIdentityTokenSource = "cannot build identity token source" errProviderNotRetrieved = "provider could not be retrieved" errFailedToSendHttpDisposableRequest = "failed to send http request" errExtractCredentials = "cannot extract credentials" @@ -181,7 +182,7 @@ type connector struct { logger logging.Logger kube client.Client usage *resource.LegacyProviderConfigUsageTracker - newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string) (httpClient.Client, error) + newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string, opts ...httpClient.Option) (httpClient.Client, error) } // Connect returns a new ExternalClient. @@ -223,7 +224,12 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E creds = string(data) } - h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), creds) + tokenSource, err := httpClient.LoadIdentityTokenSource(ctx, c.kube, pc.Spec.Identity) + if err != nil { + return nil, errors.Wrap(err, errIdentityTokenSource) + } + + h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), creds, httpClient.WithTokenSource(tokenSource)) if err != nil { return nil, errors.Wrap(err, errNewHttpClient) } diff --git a/internal/controller/cluster/request/request.go b/internal/controller/cluster/request/request.go index 162efb6..1fb3082 100644 --- a/internal/controller/cluster/request/request.go +++ b/internal/controller/cluster/request/request.go @@ -49,6 +49,7 @@ const ( errNotRequest = "managed resource is not a Request custom resource" errTrackPCUsage = "cannot track ProviderConfig usage" errNewHttpClient = "cannot create new Http client" + errIdentityTokenSource = "cannot build identity token source" errProviderNotRetrieved = "provider could not be retrieved" errFailedToSendHttpRequest = "something went wrong" errFailedToCheckIfUpToDate = "failed to check if request is up to date" @@ -96,7 +97,7 @@ type connector struct { logger logging.Logger kube client.Client usage *resource.LegacyProviderConfigUsageTracker - newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string) (httpClient.Client, error) + newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string, opts ...httpClient.Option) (httpClient.Client, error) } // Connect creates a new external client using the provider config. @@ -138,7 +139,12 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E creds = string(data) } - h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), creds) + tokenSource, err := httpClient.LoadIdentityTokenSource(ctx, c.kube, pc.Spec.Identity) + if err != nil { + return nil, errors.Wrap(err, errIdentityTokenSource) + } + + h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), creds, httpClient.WithTokenSource(tokenSource)) if err != nil { return nil, errors.Wrap(err, errNewHttpClient) } diff --git a/internal/controller/namespaced/disposablerequest/disposablerequest.go b/internal/controller/namespaced/disposablerequest/disposablerequest.go index 203c271..8fb4330 100644 --- a/internal/controller/namespaced/disposablerequest/disposablerequest.go +++ b/internal/controller/namespaced/disposablerequest/disposablerequest.go @@ -51,6 +51,7 @@ const ( errNotNamespacedDisposableRequest = "managed resource is not a namespaced DisposableRequest custom resource" errTrackPCUsage = "cannot track ProviderConfig usage" errNewHttpClient = "cannot create new Http client" + errIdentityTokenSource = "cannot build identity token source" errFailedToSendHttpDisposableRequest = "failed to send http request" errExtractCredentials = "cannot extract credentials" errResponseDoesntMatchExpectedCriteria = "response does not match expected criteria" @@ -183,7 +184,7 @@ type connector struct { logger logging.Logger kube client.Client usage *resource.ProviderConfigUsageTracker - newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string) (httpClient.Client, error) + newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string, opts ...httpClient.Option) (httpClient.Client, error) } // Connect returns a new ExternalClient. @@ -212,6 +213,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E var cd apisv1alpha2.ProviderCredentials var providerTLS *common.TLSConfig + var identity *common.Identity // Switch to ModernManaged resource to get ProviderConfigRef m := mg.(resource.ModernManaged) @@ -225,6 +227,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } cd = pc.Spec.Credentials providerTLS = pc.Spec.TLS + identity = pc.Spec.Identity case "ClusterProviderConfig": cpc := &apisv1alpha2.ClusterProviderConfig{} if err := c.kube.Get(ctx, types.NamespacedName{Name: ref.Name}, cpc); err != nil { @@ -232,6 +235,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } cd = cpc.Spec.Credentials providerTLS = cpc.Spec.TLS + identity = cpc.Spec.Identity default: return nil, errors.Errorf("unsupported provider config kind: %s", ref.Kind) } @@ -245,7 +249,12 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E creds = string(data) } - h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), creds) + tokenSource, err := httpClient.LoadIdentityTokenSource(ctx, c.kube, identity) + if err != nil { + return nil, errors.Wrap(err, errIdentityTokenSource) + } + + h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), creds, httpClient.WithTokenSource(tokenSource)) if err != nil { return nil, errors.Wrap(err, errNewHttpClient) } diff --git a/internal/controller/namespaced/request/request.go b/internal/controller/namespaced/request/request.go index 44ebbbf..8cb41be 100644 --- a/internal/controller/namespaced/request/request.go +++ b/internal/controller/namespaced/request/request.go @@ -49,6 +49,7 @@ const ( errNotRequest = "managed resource is not a namespaced Request custom resource" errTrackPCUsage = "cannot track ProviderConfig usage" errNewHttpClient = "cannot create new Http client" + errIdentityTokenSource = "cannot build identity token source" errFailedToSendHttpRequest = "something went wrong" errFailedToCheckIfUpToDate = "failed to check if request is up to date" errGetLatestVersion = "failed to get the latest version of the resource" @@ -98,7 +99,7 @@ type connector struct { logger logging.Logger kube client.Client usage *resource.ProviderConfigUsageTracker - newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string) (httpClient.Client, error) + newHttpClientFn func(log logging.Logger, timeout time.Duration, creds string, opts ...httpClient.Option) (httpClient.Client, error) } // Connect creates a new external client using the provider config. @@ -127,6 +128,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E var cd apisv1alpha2.ProviderCredentials var providerTLS *common.TLSConfig + var identity *common.Identity // Switch to ModernManaged resource to get ProviderConfigRef m := mg.(resource.ModernManaged) @@ -140,6 +142,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } cd = pc.Spec.Credentials providerTLS = pc.Spec.TLS + identity = pc.Spec.Identity case "ClusterProviderConfig": cpc := &apisv1alpha2.ClusterProviderConfig{} if err := c.kube.Get(ctx, types.NamespacedName{Name: ref.Name}, cpc); err != nil { @@ -147,6 +150,7 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } cd = cpc.Spec.Credentials providerTLS = cpc.Spec.TLS + identity = cpc.Spec.Identity default: return nil, errors.Errorf("unsupported provider config kind: %s", ref.Kind) } @@ -156,7 +160,12 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E return nil, errors.Wrap(err, errExtractCredentials) } - h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), string(data)) + tokenSource, err := httpClient.LoadIdentityTokenSource(ctx, c.kube, identity) + if err != nil { + return nil, errors.Wrap(err, errIdentityTokenSource) + } + + h, err := c.newHttpClientFn(l, utils.WaitTimeout(cr.Spec.ForProvider.WaitTimeout), string(data), httpClient.WithTokenSource(tokenSource)) if err != nil { return nil, errors.Wrap(err, errNewHttpClient) } diff --git a/package/crds/http.crossplane.io_providerconfigs.yaml b/package/crds/http.crossplane.io_providerconfigs.yaml index 12382df..89b455c 100644 --- a/package/crds/http.crossplane.io_providerconfigs.yaml +++ b/package/crds/http.crossplane.io_providerconfigs.yaml @@ -103,6 +103,82 @@ spec: required: - source type: object + identity: + description: |- + Identity used to authenticate outgoing requests. The identity + credentials supplement 'credentials' by configuring a bearer token + source such as OAuth. A request that carries its own Authorization + header keeps that value. + properties: + env: + description: |- + Env is a reference to an environment variable that contains credentials + that must be used to connect to the provider. + properties: + name: + description: Name is the name of an environment variable. + type: string + required: + - name + type: object + fs: + description: |- + Fs is a reference to a filesystem location that contains credentials that + must be used to connect to the provider. + properties: + path: + description: Path is a filesystem path. + type: string + required: + - path + type: object + scopes: + description: |- + Scopes requested for the access token. Defaults to + https://www.googleapis.com/auth/cloud-platform for + GoogleApplicationCredentials. + items: + type: string + type: array + secretRef: + description: |- + A SecretRef is a reference to a secret key that contains the credentials + that must be used to connect to the provider. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + source: + description: |- + Source of the identity credentials. Use InjectedIdentity to resolve + credentials from the provider pod's environment, for example through + Workload Identity on GKE. + enum: + - Secret + - InjectedIdentity + - Environment + - Filesystem + type: string + type: + description: Type of identity. + enum: + - GoogleApplicationCredentials + type: string + required: + - source + - type + type: object tls: description: TLS configuration for HTTPS requests. properties: diff --git a/package/crds/http.m.crossplane.io_clusterproviderconfigs.yaml b/package/crds/http.m.crossplane.io_clusterproviderconfigs.yaml index a7d185b..a6216ac 100644 --- a/package/crds/http.m.crossplane.io_clusterproviderconfigs.yaml +++ b/package/crds/http.m.crossplane.io_clusterproviderconfigs.yaml @@ -104,6 +104,82 @@ spec: required: - source type: object + identity: + description: |- + Identity used to authenticate outgoing requests. The identity + credentials supplement 'credentials' by configuring a bearer token + source such as OAuth. A request that carries its own Authorization + header keeps that value. + properties: + env: + description: |- + Env is a reference to an environment variable that contains credentials + that must be used to connect to the provider. + properties: + name: + description: Name is the name of an environment variable. + type: string + required: + - name + type: object + fs: + description: |- + Fs is a reference to a filesystem location that contains credentials that + must be used to connect to the provider. + properties: + path: + description: Path is a filesystem path. + type: string + required: + - path + type: object + scopes: + description: |- + Scopes requested for the access token. Defaults to + https://www.googleapis.com/auth/cloud-platform for + GoogleApplicationCredentials. + items: + type: string + type: array + secretRef: + description: |- + A SecretRef is a reference to a secret key that contains the credentials + that must be used to connect to the provider. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + source: + description: |- + Source of the identity credentials. Use InjectedIdentity to resolve + credentials from the provider pod's environment, for example through + Workload Identity on GKE. + enum: + - Secret + - InjectedIdentity + - Environment + - Filesystem + type: string + type: + description: Type of identity. + enum: + - GoogleApplicationCredentials + type: string + required: + - source + - type + type: object tls: description: TLS configuration for HTTPS requests. properties: diff --git a/package/crds/http.m.crossplane.io_providerconfigs.yaml b/package/crds/http.m.crossplane.io_providerconfigs.yaml index 4dd64f3..d259871 100644 --- a/package/crds/http.m.crossplane.io_providerconfigs.yaml +++ b/package/crds/http.m.crossplane.io_providerconfigs.yaml @@ -103,6 +103,82 @@ spec: required: - source type: object + identity: + description: |- + Identity used to authenticate outgoing requests. The identity + credentials supplement 'credentials' by configuring a bearer token + source such as OAuth. A request that carries its own Authorization + header keeps that value. + properties: + env: + description: |- + Env is a reference to an environment variable that contains credentials + that must be used to connect to the provider. + properties: + name: + description: Name is the name of an environment variable. + type: string + required: + - name + type: object + fs: + description: |- + Fs is a reference to a filesystem location that contains credentials that + must be used to connect to the provider. + properties: + path: + description: Path is a filesystem path. + type: string + required: + - path + type: object + scopes: + description: |- + Scopes requested for the access token. Defaults to + https://www.googleapis.com/auth/cloud-platform for + GoogleApplicationCredentials. + items: + type: string + type: array + secretRef: + description: |- + A SecretRef is a reference to a secret key that contains the credentials + that must be used to connect to the provider. + properties: + key: + description: The key to select. + type: string + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - key + - name + - namespace + type: object + source: + description: |- + Source of the identity credentials. Use InjectedIdentity to resolve + credentials from the provider pod's environment, for example through + Workload Identity on GKE. + enum: + - Secret + - InjectedIdentity + - Environment + - Filesystem + type: string + type: + description: Type of identity. + enum: + - GoogleApplicationCredentials + type: string + required: + - source + - type + type: object tls: description: TLS configuration for HTTPS requests. properties: