diff --git a/AGENTS.md b/AGENTS.md index bb0a36cfa..b453af2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ There is **no top-level `go.mod`**. Each directory is its own module: - `runtime/` — largest module. Sub-packages: `conditions`, `patch`, `reconcile`, `events`, `metrics`, `features`, `cel`, `acl`, `controller`, `dependency`, `errors`, `jitter`, `leaderelection`, `logger`, `object`, `predicates`, `probes`, `pprof`, `secrets`, `statusreaders`, `testenv`, `transform`, `client`. - `ssa/` — server-side apply engine (`ResourceManager`): apply, diff, wait, delete, change sets. Sub-packages: `jsondiff`, `normalize`, `errors`, `utils`. - `git/` — git operations. `gogit/` sub-package is the concrete go-git implementation. `repository/` defines `Reader`/`Writer` interfaces. -- `auth/` — cloud workload identity: `aws/`, `azure/`, `gcp/`, `generic/`, `githubapp/`, `utils/`. Central `GetAccessToken()` with caching. +- `auth/` — cloud workload identity: `aws/`, `azure/`, `gcp/`, `generic/`, `githubapp/`, `utils/`. Central `NewAccessCredential()` with caching. - `artifact/` — artifact storage: `config/`, `digest/`, `server/`, `storage/`. - `oci/` — OCI registry client (push, pull, tag, list, build, diff, delete). - `kustomize/` — kustomize generator and variable substitution. `filesys/` provides secure filesystem implementations. diff --git a/README.md b/README.md index 86e120bb5..fe6ff506f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ### Authentication & Security - **[github.com/fluxcd/pkg/auth](./auth)** - OIDC-based authentication with cloud providers (AWS, Azure, GCP) - **[github.com/fluxcd/pkg/masktoken](./masktoken)** - Token redaction utilities for secure logging -- **[github.com/fluxcd/pkg/spiffe](./spiffe)** - SPIFFE Workload API X.509-SVID utilities for mutual TLS +- **[github.com/fluxcd/pkg/spiffe](./spiffe)** - SPIFFE Workload and Broker API utilities - **[github.com/fluxcd/pkg/ssh](./ssh)** - SSH host key scanning and management ### Artifact Storage & Serving diff --git a/auth/access_credential.go b/auth/access_credential.go new file mode 100644 index 000000000..ceb6bf5f3 --- /dev/null +++ b/auth/access_credential.go @@ -0,0 +1,255 @@ +/* +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 auth + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + + cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" + "github.com/fluxcd/pkg/auth/kubernetes" + "github.com/fluxcd/pkg/cache" + "github.com/fluxcd/pkg/spiffe/broker" +) + +// NewAccessCredential returns an access credential for accessing resources in +// the given cloud provider. +func NewAccessCredential(ctx context.Context, provider Provider, opts ...Option) (Credential, error) { + + var o Options + o.Apply(opts...) + + // Initialize access credential fetcher for the controller. + newAccessCredential := func() (Credential, error) { + credential, err := provider.NewControllerToken(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create provider access credential for the controller: %w", err) + } + return credential, nil + } + + // Update access credential fetcher for a ServiceAccount if specified. + var serviceAccount *kubernetes.ServiceAccountReference + var providerIdentity string + var audiences []string + if o.objectLevelWorkloadIdentity() { + var err error + serviceAccount, audiences, providerIdentity, err = + getServiceAccountAndProviderInfo(ctx, provider, opts...) + if err != nil { + return nil, err + } + + // Update the function to create an access credential using the service account. + newAccessCredential = func() (Credential, error) { + identity, err := o.oidcCredential(ctx, audiences) + if err != nil { + return nil, err + } + + // Exchange the identity credential for a provider access credential. + credential, err := provider.NewAccessCredentialForIdentity(ctx, identity, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create provider access credential for service account '%s': %w", + serviceAccount, err) + } + + return credential, nil + } + } + + // Bail out early if cache is disabled. + if o.Cache == nil { + return newAccessCredential() + } + + // Build cache key. + cacheKey := buildAccessCredentialCacheKey(provider, audiences, providerIdentity, serviceAccount, opts...) + + // Get credential from cache. + credential, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) { + return newAccessCredential() + }, cache.WithInvolvedObject(o.involvedObject())) + if err != nil { + return nil, err + } + + return credential, nil +} + +// oidcCredential returns the OIDC credential used to obtain a provider access +// credential for the object. Kubernetes issues a ServiceAccount token, while +// SPIFFE fetches a JWT-SVID for the object from the SPIFFE Broker. +func (o *Options) oidcCredential(ctx context.Context, audiences []string) (Credential, error) { + switch o.credentialProvider() { + case cryptov1.CredentialProviderKubernetes: + sa := o.ServiceAccountRef() + token, err := kubernetes.CreateJWT(ctx, o.Client, sa, audiences, o.CredentialExpiration) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes token for service account '%s': %w", sa, err) + } + return newJWTCredential(cryptov1.CredentialProviderKubernetes, token) + + case cryptov1.CredentialProviderSPIFFE: + if o.credentialType() != cryptov1.CredentialTypeJWT { + return nil, fmt.Errorf("credential type '%s' for the %s credential provider is not implemented yet", + o.credentialType(), o.credentialProvider()) + } + if o.SPIFFEBroker == nil { + return nil, errors.New("SPIFFE Broker client is required for the SPIFFE credential provider") + } + if o.ObjectReference.Kind == "" { + return nil, errors.New("object reference is required for the SPIFFE credential provider") + } + + svid, err := o.SPIFFEBroker.FetchJWTSVID(ctx, broker.KubernetesObjectReference{ + Group: o.ObjectReference.APIGroup, + Kind: o.ObjectReference.Kind, + Namespace: o.ObjectReference.Namespace, + Name: o.ObjectReference.Name, + UID: o.ObjectReference.UID, + }, audiences) + if err != nil { + return nil, fmt.Errorf("failed to fetch SPIFFE JWT-SVID for object '%s/%s %s/%s': %w", + o.ObjectReference.APIGroup, o.ObjectReference.Kind, + o.ObjectReference.Namespace, o.ObjectReference.Name, err) + } + return newJWTCredential(cryptov1.CredentialProviderSPIFFE, svid.Marshal()) + + default: + return nil, fmt.Errorf("credential provider '%s' is not supported", o.credentialProvider()) + } +} + +// newJWTCredential returns a JWTCredential for the given provider and +// serialized JWT, extracting the expiration time from the token. +func newJWTCredential(provider, token string) (*JWTCredential, error) { + parsed, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT: %w", err) + } + expiration, err := parsed.Claims.GetExpirationTime() + if err != nil { + return nil, fmt.Errorf("failed to get JWT expiration: %w", err) + } + var expiresAt time.Time + if expiration != nil { + expiresAt = expiration.Time + } + return &JWTCredential{ + Provider: provider, + Token: token, + ExpiresAt: expiresAt, + }, nil +} + +func getServiceAccountAndProviderInfo(ctx context.Context, provider Provider, + opts ...Option) (*kubernetes.ServiceAccountReference, []string, string, error) { + var o Options + o.Apply(opts...) + + sa := o.ServiceAccountRef() + + // Get provider audience. The provider decides whether any configured + // credential audiences are used. + audiences, err := provider.GetAudiences(ctx, opts...) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to get provider audience: %w", err) + } + + // Get provider identity. + providerIdentity, err := provider.GetIdentity(opts...) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to get provider identity from service account '%s': %w", + sa, err) + } + + return sa, audiences, providerIdentity, nil +} + +func buildAccessCredentialCacheKey(provider Provider, audiences []string, providerIdentity string, + serviceAccount *kubernetes.ServiceAccountReference, opts ...Option) string { + + var o Options + o.Apply(opts...) + + var parts []string + + parts = append(parts, fmt.Sprintf("provider=%s", provider.GetName())) + + if o.CredentialProvider != "" { + parts = append(parts, fmt.Sprintf("credentialProvider=%s", o.CredentialProvider)) + parts = append(parts, fmt.Sprintf("credentialType=%s", o.credentialType())) + } + + if len(audiences) == 0 { + audiences = o.CredentialAudiences + } + if len(audiences) > 0 { + parts = append(parts, fmt.Sprintf("audiences=%s", strings.Join(audiences, ","))) + } + + if serviceAccount != nil { + parts = append(parts, fmt.Sprintf("providerIdentity=%s", providerIdentity)) + if o.credentialProvider() == cryptov1.CredentialProviderSPIFFE { + // Even if the object shares its SPIFFE ID with others in pratice, + // Flux has to assume it doesn't, so the full object reference must + // be included in the cache key for SPIFFE. This means different + // objects will never share cached credentials when using SPIFFE + // (but the same object may still reuse its cached credentials). + parts = append(parts, + fmt.Sprintf("objectGroup=%s", o.ObjectReference.APIGroup), + fmt.Sprintf("objectKind=%s", o.ObjectReference.Kind), + fmt.Sprintf("objectNamespace=%s", o.ObjectReference.Namespace), + fmt.Sprintf("objectName=%s", o.ObjectReference.Name), + fmt.Sprintf("objectUID=%s", o.ObjectReference.UID), + ) + } else { + parts = append(parts, + fmt.Sprintf("serviceAccountName=%s", serviceAccount.Name), + fmt.Sprintf("serviceAccountNamespace=%s", serviceAccount.Namespace), + ) + } + } + + if len(o.Scopes) > 0 { + parts = append(parts, fmt.Sprintf("scopes=%s", strings.Join(o.Scopes, ","))) + } + + if o.AWSRegion != "" { + parts = append(parts, fmt.Sprintf("stsRegion=%s", o.AWSRegion)) + } + + if o.AWSSTSEndpoint != "" { + parts = append(parts, fmt.Sprintf("stsEndpoint=%s", o.AWSSTSEndpoint)) + } + + if o.ProxyURL != nil { + parts = append(parts, fmt.Sprintf("proxyURL=%s", o.ProxyURL)) + } + + if o.CAData != "" { + parts = append(parts, fmt.Sprintf("caData=%s", o.CAData)) + } + + return buildCacheKey(parts...) +} diff --git a/auth/access_credential_test.go b/auth/access_credential_test.go new file mode 100644 index 000000000..bd01ca9ba --- /dev/null +++ b/auth/access_credential_test.go @@ -0,0 +1,561 @@ +/* +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 auth_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "net/url" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + . "github.com/onsi/gomega" + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" + "github.com/fluxcd/pkg/auth" + "github.com/fluxcd/pkg/auth/kubernetes" + "github.com/fluxcd/pkg/cache" + "github.com/fluxcd/pkg/spiffe/broker" +) + +func TestNewAccessCredential(t *testing.T) { + g := NewWithT(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + kubeClient, oidcClient := newTestEnv(t, ctx) + + // Create a default service account. + defaultServiceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: "default", + }, + } + err := kubeClient.Create(ctx, defaultServiceAccount) + g.Expect(err).NotTo(HaveOccurred()) + + // Create a lockdown service account for testing lockdown functionality. + lockdownServiceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "lockdown-sa", + Namespace: "default", + }, + } + err = kubeClient.Create(ctx, lockdownServiceAccount) + g.Expect(err).NotTo(HaveOccurred()) + + for _, tt := range []struct { + name string + provider *mockProvider + opts []auth.Option + expectedCredential auth.Credential + expectedErr string + }{ + { + name: "controller access credential", + provider: &mockProvider{ + returnControllerCredential: &mockCredential{token: "mock-default-token"}, + }, + opts: []auth.Option{ + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + }, + expectedCredential: &mockCredential{token: "mock-default-token"}, + }, + { + name: "controller access credential allowing shell out", + provider: &mockProvider{ + returnControllerCredential: &mockCredential{token: "mock-default-token"}, + paramAllowShellOut: true, + }, + opts: []auth.Option{ + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + auth.WithAllowShellOut(), + }, + expectedCredential: &mockCredential{token: "mock-default-token"}, + }, + { + name: "access credential from explicit service account", + provider: &mockProvider{ + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: lockdownServiceAccount.Name, Namespace: lockdownServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(lockdownServiceAccount.Name), + auth.WithObject("", "ServiceAccount", lockdownServiceAccount), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + }, + expectedCredential: &mockCredential{token: "mock-access-token"}, + }, + { + name: "access credential from service account", + provider: &mockProvider{ + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + // Exercise the code path where a cache is set but no token is + // available in the cache. + func(o *auth.Options) { + tokenCache, err := cache.NewTokenCache(1) + g.Expect(err).NotTo(HaveOccurred()) + o.Cache = tokenCache + }, + }, + expectedCredential: &mockCredential{token: "mock-access-token"}, + }, + { + name: "access credential from service account with credential expiration", + provider: &mockProvider{ + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, + paramAudiences: []string{"audience1"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + paramOIDCTokenExpiry: 15 * time.Minute, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithCredentialAudiences("audience1"), + auth.WithCredentialExpiration(15 * time.Minute), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + }, + expectedCredential: &mockCredential{token: "mock-access-token"}, + }, + { + name: "access credential from service account with explicit name ignoring default", + provider: &mockProvider{ + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + }, + expectedCredential: &mockCredential{token: "mock-access-token"}, + }, + { + name: "access credential from service account - default audience", + provider: &mockProvider{ + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, + paramAudiences: []string{}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + }, + expectedCredential: &mockCredential{token: "mock-access-token"}, + }, + { + name: "all the options are taken into account in the cache key", + provider: &mockProvider{ + returnName: "mock-provider", + returnIdentity: "mock-identity", + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + func(o *auth.Options) { + tokenCache, err := cache.NewTokenCache(1) + g.Expect(err).NotTo(HaveOccurred()) + + const key = "6c016e84cc74ad7bf6bd8770cdb6a2efca3e185fdf90badd985c77984784f5c3" + token := &mockCredential{token: "cached-token"} + cachedCredential, ok, err := tokenCache.GetOrSet(ctx, key, func(ctx context.Context) (cache.Token, error) { + return token, nil + }) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ok).To(BeFalse()) + g.Expect(cachedCredential).To(Equal(token)) + + o.Cache = tokenCache + }, + }, + expectedCredential: &mockCredential{token: "cached-token"}, + }, + { + name: "error getting identity", + provider: &mockProvider{ + returnIdentityErr: "mock error", + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + }, + expectedErr: "failed to get provider identity from service account 'default/default': mock error", + }, + { + name: "error getting identity using cache", + provider: &mockProvider{ + returnIdentityErr: "mock error", + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + }, + opts: []auth.Option{ + auth.WithClient(kubeClient), + auth.WithServiceAccountName(defaultServiceAccount.Name), + auth.WithObject("", "ServiceAccount", defaultServiceAccount), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + func(o *auth.Options) { + tokenCache, err := cache.NewTokenCache(1) + g.Expect(err).NotTo(HaveOccurred()) + o.Cache = tokenCache + }, + }, + expectedErr: "failed to get provider identity from service account 'default/default': mock error", + }, + } { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tt.provider.t = t + + credential, err := auth.NewAccessCredential(ctx, tt.provider, tt.opts...) + + if tt.expectedErr != "" { + g.Expect(err).To(MatchError(ContainSubstring(tt.expectedErr))) + g.Expect(credential).To(BeNil()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(credential).To(Equal(tt.expectedCredential)) + } + }) + } +} + +// TestNewAccessCredential_ControllerLevelAudienceCacheKey is a regression test for a +// credential cache collision: controller-level (no service account) access credentials are +// also audience-scoped (e.g. SOPS Vault/OpenBao decryption and kubeconfig +// ServiceAccount credentials), so requesting a credential for one audience must never +// return a cached credential that was minted for a different audience. +func TestNewAccessCredential_ControllerLevelAudienceCacheKey(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + tokenCache, err := cache.NewTokenCache(3) + g.Expect(err).NotTo(HaveOccurred()) + + provider := &mockProvider{t: t, returnName: "mock-provider"} + + // Controller-level options (no service account) for a given audience. The + // non-audience options exist only to satisfy the mock provider's checks. + opts := func(audience string) []auth.Option { + provider.paramAudiences = []string{audience} + return []auth.Option{ + auth.WithCredentialAudiences(audience), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + auth.WithObject("", "test", &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + }), + auth.WithCache(*tokenCache), + } + } + + // Mint and cache a controller credential for the first audience. + provider.returnControllerCredential = &mockCredential{token: "token-audience-a"} + got, err := auth.NewAccessCredential(ctx, provider, opts("audience-a")...) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(&mockCredential{token: "token-audience-a"})) + + // A different audience must NOT reuse the first audience's cached credential. + provider.returnControllerCredential = &mockCredential{token: "token-audience-b"} + got, err = auth.NewAccessCredential(ctx, provider, opts("audience-b")...) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(&mockCredential{token: "token-audience-b"})) + + // The first audience still returns its own cached credential (per-audience cache); + // the refreshed controller credential must not be minted. + provider.returnControllerCredential = &mockCredential{token: "token-audience-a-fresh"} + got, err = auth.NewAccessCredential(ctx, provider, opts("audience-a")...) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(&mockCredential{token: "token-audience-a"})) +} + +type mockBrokerClient struct { + t *testing.T + token string + calls int + refs []broker.KubernetesObjectReference + requests [][]string +} + +func (m *mockBrokerClient) Close() error { + return nil +} + +func (m *mockBrokerClient) FetchJWTSVID(_ context.Context, ref broker.KubernetesObjectReference, + audiences []string) (*jwtsvid.SVID, error) { + + m.t.Helper() + m.calls++ + m.refs = append(m.refs, ref) + m.requests = append(m.requests, audiences) + return jwtsvid.ParseInsecure(m.token, audiences) +} + +func TestNewAccessCredentialSPIFFE(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + object := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "object", + Namespace: "flux-system", + UID: "object-uid", + }, + } + brokerClient := &mockBrokerClient{ + t: t, + token: newSPIFFEJWTSVID(t, []string{"audience1", "audience2"}, time.Hour), + } + provider := spiffeMockProvider(t) + provider.paramOIDCToken = brokerClient.token + + opts := spiffeOptions(object, brokerClient) + credential, err := auth.NewAccessCredential(ctx, provider, opts...) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(credential).To(Equal(&mockCredential{token: "mock-access-token"})) + g.Expect(brokerClient.calls).To(Equal(1)) + g.Expect(brokerClient.refs).To(Equal([]broker.KubernetesObjectReference{{ + Group: "source.toolkit.fluxcd.io", + Kind: "OCIRepository", + Namespace: "flux-system", + Name: "object", + UID: "object-uid", + }})) + g.Expect(brokerClient.requests).To(Equal([][]string{{"audience1", "audience2"}})) +} + +func TestNewAccessCredentialSPIFFECacheKey(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + tokenCache, err := cache.NewTokenCache(3) + g.Expect(err).NotTo(HaveOccurred()) + + brokerClient := &mockBrokerClient{ + t: t, + token: newSPIFFEJWTSVID(t, []string{"audience1", "audience2"}, time.Hour), + } + provider := spiffeMockProvider(t) + + objectA := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "object-a", + Namespace: "flux-system", + UID: "object-a-uid", + }, + } + objectB := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "object-b", + Namespace: "flux-system", + UID: "object-b-uid", + }, + } + + // Same object: the second call is served from the cache. + for range 2 { + _, err := auth.NewAccessCredential(ctx, provider, + spiffeOptionsWithCache(objectA, brokerClient, tokenCache)...) + g.Expect(err).NotTo(HaveOccurred()) + } + g.Expect(brokerClient.calls).To(Equal(1)) + + // Same ServiceAccount, different object: a new SPIFFE JWT-SVID is fetched. + _, err = auth.NewAccessCredential(ctx, provider, + spiffeOptionsWithCache(objectB, brokerClient, tokenCache)...) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(brokerClient.calls).To(Equal(2)) + g.Expect(brokerClient.refs[1].UID).To(Equal("object-b-uid")) +} + +func TestNewAccessCredentialSPIFFERequiresBroker(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + object := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "object", + Namespace: "flux-system", + UID: "object-uid", + }, + } + + opts := spiffeOptions(object, nil) + _, err := auth.NewAccessCredential(ctx, spiffeMockProvider(t), opts...) + g.Expect(err).To(MatchError("SPIFFE Broker client is required for the SPIFFE credential provider")) +} + +func spiffeMockProvider(t *testing.T) *mockProvider { + return &mockProvider{ + t: t, + returnName: "mock-provider", + returnIdentity: "mock-identity", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{ + Name: "spiffe-sa", + Namespace: "flux-system", + }, + skipOIDCValidation: true, + } +} + +func spiffeOptions(object *corev1.ServiceAccount, brokerClient broker.Interface) []auth.Option { + return []auth.Option{ + auth.WithServiceAccountName("spiffe-sa"), + auth.WithObject("source.toolkit.fluxcd.io", "OCIRepository", object), + auth.WithCredentialProvider(cryptov1.CredentialProviderSPIFFE), + auth.WithCredentialType(cryptov1.CredentialTypeJWT), + auth.WithCredentialAudiences("audience1", "audience2"), + auth.WithSPIFFEBroker(brokerClient), + auth.WithScopes("scope1", "scope2"), + auth.WithAWSRegion("us-east-1"), + auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), + auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), + auth.WithCAData("ca-data"), + } +} + +func spiffeOptionsWithCache(object *corev1.ServiceAccount, brokerClient broker.Interface, + tokenCache *cache.TokenCache) []auth.Option { + return append(spiffeOptions(object, brokerClient), auth.WithCache(*tokenCache)) +} + +func newSPIFFEJWTSVID(t *testing.T, audiences []string, ttl time.Duration) string { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate JWT key: %v", err) + } + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, nil) + if err != nil { + t.Fatalf("failed to create JWT signer: %v", err) + } + claims, err := json.Marshal(map[string]any{ + "sub": "spiffe://example.org/ns/flux-system/ocirepository/object", + "aud": audiences, + "exp": time.Now().Add(ttl).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + }) + if err != nil { + t.Fatalf("failed to marshal JWT claims: %v", err) + } + jws, err := signer.Sign(claims) + if err != nil { + t.Fatalf("failed to sign JWT: %v", err) + } + token, err := jws.CompactSerialize() + if err != nil { + t.Fatalf("failed to serialize JWT: %v", err) + } + return token +} diff --git a/auth/access_token.go b/auth/access_token.go deleted file mode 100644 index 4593e11ad..000000000 --- a/auth/access_token.go +++ /dev/null @@ -1,164 +0,0 @@ -/* -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 auth - -import ( - "context" - "fmt" - "strings" - - "github.com/fluxcd/pkg/auth/kubernetes" - "github.com/fluxcd/pkg/cache" -) - -// GetAccessToken returns an access token for accessing resources in the given cloud provider. -func GetAccessToken(ctx context.Context, provider Provider, opts ...Option) (Token, error) { - - var o Options - o.Apply(opts...) - - // Initialize access token fetcher for controller. - newAccessToken := func() (Token, error) { - token, err := provider.NewControllerToken(ctx, opts...) - if err != nil { - return nil, fmt.Errorf("failed to create provider access token for the controller: %w", err) - } - return token, nil - } - - // Update access token fetcher for a service account if specified. - var serviceAccount *kubernetes.ServiceAccountReference - var providerIdentity string - var audiences []string - if o.ObjectLevelWorkloadIdentity() { - var err error - serviceAccount, audiences, providerIdentity, err = - getServiceAccountAndProviderInfo(ctx, provider, opts...) - if err != nil { - return nil, err - } - - // Update the function to create an access token using the service account. - newAccessToken = func() (Token, error) { - // Issue Kubernetes OIDC token for the service account. - oidcToken, err := kubernetes.CreateJWT(ctx, o.Client, serviceAccount, - audiences, o.CredentialExpiration) - if err != nil { - return nil, fmt.Errorf("failed to create kubernetes token for service account '%s': %w", - serviceAccount, err) - } - - // Exchange the Kubernetes OIDC token for a provider access token. - token, err := provider.NewTokenForServiceAccount(ctx, oidcToken, opts...) - if err != nil { - return nil, fmt.Errorf("failed to create provider access token for service account '%s': %w", - serviceAccount, err) - } - - return token, nil - } - } - - // Bail out early if cache is disabled. - if o.Cache == nil { - return newAccessToken() - } - - // Build cache key. - cacheKey := buildAccessTokenCacheKey(provider, audiences, providerIdentity, serviceAccount, opts...) - - // Get token from cache. - token, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) { - return newAccessToken() - }, cache.WithInvolvedObject(o.involvedObject())) - if err != nil { - return nil, err - } - - return token, nil -} - -func getServiceAccountAndProviderInfo(ctx context.Context, provider Provider, - opts ...Option) (*kubernetes.ServiceAccountReference, []string, string, error) { - - var o Options - o.Apply(opts...) - - sa := o.ServiceAccountRef() - - // Get provider audience. The provider decides whether any configured - // credential audiences are used. - audiences, err := provider.GetAudiences(ctx, opts...) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to get provider audience: %w", err) - } - - // Get provider identity. - providerIdentity, err := provider.GetIdentity(opts...) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to get provider identity from service account '%s': %w", - sa, err) - } - - return sa, audiences, providerIdentity, nil -} - -func buildAccessTokenCacheKey(provider Provider, audiences []string, providerIdentity string, - serviceAccount *kubernetes.ServiceAccountReference, opts ...Option) string { - - var o Options - o.Apply(opts...) - - var parts []string - - parts = append(parts, fmt.Sprintf("provider=%s", provider.GetName())) - - if len(audiences) == 0 { - audiences = o.CredentialAudiences - } - if len(audiences) > 0 { - parts = append(parts, fmt.Sprintf("audiences=%s", strings.Join(audiences, ","))) - } - - if serviceAccount != nil { - parts = append(parts, fmt.Sprintf("providerIdentity=%s", providerIdentity)) - parts = append(parts, fmt.Sprintf("serviceAccountName=%s", serviceAccount.Name)) - parts = append(parts, fmt.Sprintf("serviceAccountNamespace=%s", serviceAccount.Namespace)) - } - - if len(o.Scopes) > 0 { - parts = append(parts, fmt.Sprintf("scopes=%s", strings.Join(o.Scopes, ","))) - } - - if o.AWSRegion != "" { - parts = append(parts, fmt.Sprintf("stsRegion=%s", o.AWSRegion)) - } - - if o.AWSSTSEndpoint != "" { - parts = append(parts, fmt.Sprintf("stsEndpoint=%s", o.AWSSTSEndpoint)) - } - - if o.ProxyURL != nil { - parts = append(parts, fmt.Sprintf("proxyURL=%s", o.ProxyURL)) - } - - if o.CAData != "" { - parts = append(parts, fmt.Sprintf("caData=%s", o.CAData)) - } - - return buildCacheKey(parts...) -} diff --git a/auth/access_token_test.go b/auth/access_token_test.go deleted file mode 100644 index 9c00cae52..000000000 --- a/auth/access_token_test.go +++ /dev/null @@ -1,370 +0,0 @@ -/* -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 auth_test - -import ( - "context" - "net/url" - "testing" - "time" - - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/fluxcd/pkg/auth" - "github.com/fluxcd/pkg/auth/kubernetes" - "github.com/fluxcd/pkg/cache" -) - -func TestGetAccessToken(t *testing.T) { - g := NewWithT(t) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - kubeClient, oidcClient := newTestEnv(t, ctx) - - // Create a default service account. - defaultServiceAccount := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: "default", - Namespace: "default", - }, - } - err := kubeClient.Create(ctx, defaultServiceAccount) - g.Expect(err).NotTo(HaveOccurred()) - - // Create a lockdown service account for testing lockdown functionality. - lockdownServiceAccount := &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: "lockdown-sa", - Namespace: "default", - }, - } - err = kubeClient.Create(ctx, lockdownServiceAccount) - g.Expect(err).NotTo(HaveOccurred()) - - for _, tt := range []struct { - name string - provider *mockProvider - opts []auth.Option - expectedToken auth.Token - expectedErr string - }{ - { - name: "controller access token", - provider: &mockProvider{ - returnControllerToken: &mockToken{token: "mock-default-token"}, - }, - opts: []auth.Option{ - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - }, - expectedToken: &mockToken{token: "mock-default-token"}, - }, - { - name: "controller access token allowing shell out", - provider: &mockProvider{ - returnControllerToken: &mockToken{token: "mock-default-token"}, - paramAllowShellOut: true, - }, - opts: []auth.Option{ - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - auth.WithAllowShellOut(), - }, - expectedToken: &mockToken{token: "mock-default-token"}, - }, - { - name: "access token from explicit service account", - provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, - paramAudiences: []string{"audience1", "audience2"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: lockdownServiceAccount.Name, Namespace: lockdownServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(lockdownServiceAccount.Name), - auth.WithObject("", "ServiceAccount", lockdownServiceAccount), - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - }, - expectedToken: &mockToken{token: "mock-access-token"}, - }, - { - name: "access token from service account", - provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, - paramAudiences: []string{"audience1", "audience2"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - // Exercise the code path where a cache is set but no token is - // available in the cache. - func(o *auth.Options) { - tokenCache, err := cache.NewTokenCache(1) - g.Expect(err).NotTo(HaveOccurred()) - o.Cache = tokenCache - }, - }, - expectedToken: &mockToken{token: "mock-access-token"}, - }, - { - name: "access token from service account with credential expiration", - provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, - paramAudiences: []string{"audience1"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - paramOIDCTokenExpiry: 15 * time.Minute, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithCredentialAudiences("audience1"), - auth.WithCredentialExpiration(15 * time.Minute), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - }, - expectedToken: &mockToken{token: "mock-access-token"}, - }, - { - name: "access token from service account with explicit name ignoring default", - provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, - paramAudiences: []string{"audience1", "audience2"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - }, - expectedToken: &mockToken{token: "mock-access-token"}, - }, - { - name: "access token from service account - default audience", - provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, - paramAudiences: []string{}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - }, - expectedToken: &mockToken{token: "mock-access-token"}, - }, - { - name: "all the options are taken into account in the cache key", - provider: &mockProvider{ - returnName: "mock-provider", - returnIdentity: "mock-identity", - paramAudiences: []string{"audience1", "audience2"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - func(o *auth.Options) { - tokenCache, err := cache.NewTokenCache(1) - g.Expect(err).NotTo(HaveOccurred()) - - const key = "6c016e84cc74ad7bf6bd8770cdb6a2efca3e185fdf90badd985c77984784f5c3" - token := &mockToken{token: "cached-token"} - cachedToken, ok, err := tokenCache.GetOrSet(ctx, key, func(ctx context.Context) (cache.Token, error) { - return token, nil - }) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) - - o.Cache = tokenCache - }, - }, - expectedToken: &mockToken{token: "cached-token"}, - }, - { - name: "error getting identity", - provider: &mockProvider{ - returnIdentityErr: "mock error", - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - }, - expectedErr: "failed to get provider identity from service account 'default/default': mock error", - }, - { - name: "error getting identity using cache", - provider: &mockProvider{ - returnIdentityErr: "mock error", - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - }, - opts: []auth.Option{ - auth.WithClient(kubeClient), - auth.WithServiceAccountName(defaultServiceAccount.Name), - auth.WithObject("", "ServiceAccount", defaultServiceAccount), - auth.WithCredentialAudiences("audience1", "audience2"), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - func(o *auth.Options) { - tokenCache, err := cache.NewTokenCache(1) - g.Expect(err).NotTo(HaveOccurred()) - o.Cache = tokenCache - }, - }, - expectedErr: "failed to get provider identity from service account 'default/default': mock error", - }, - } { - t.Run(tt.name, func(t *testing.T) { - g := NewWithT(t) - - tt.provider.t = t - - token, err := auth.GetAccessToken(ctx, tt.provider, tt.opts...) - - if tt.expectedErr != "" { - g.Expect(err).To(MatchError(ContainSubstring(tt.expectedErr))) - g.Expect(token).To(BeNil()) - } else { - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(token).To(Equal(tt.expectedToken)) - } - }) - } -} - -// TestGetAccessToken_ControllerLevelAudienceCacheKey is a regression test for a -// token cache collision: controller-level (no service account) access tokens are -// also audience-scoped (e.g. SOPS Vault/OpenBao decryption and kubeconfig -// ServiceAccount tokens), so requesting a token for one audience must never -// return a cached token that was minted for a different audience. -func TestGetAccessToken_ControllerLevelAudienceCacheKey(t *testing.T) { - g := NewWithT(t) - ctx := context.Background() - - tokenCache, err := cache.NewTokenCache(3) - g.Expect(err).NotTo(HaveOccurred()) - - provider := &mockProvider{t: t, returnName: "mock-provider"} - - // Controller-level options (no service account) for a given audience. The - // non-audience options exist only to satisfy the mock provider's checks. - opts := func(audience string) []auth.Option { - provider.paramAudiences = []string{audience} - return []auth.Option{ - auth.WithCredentialAudiences(audience), - auth.WithScopes("scope1", "scope2"), - auth.WithAWSRegion("us-east-1"), - auth.WithAWSSTSEndpoint("https://sts.some-cloud.io"), - auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}), - auth.WithCAData("ca-data"), - auth.WithObject("", "test", &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test", - }, - }), - auth.WithCache(*tokenCache), - } - } - - // Mint and cache a controller token for the first audience. - provider.returnControllerToken = &mockToken{token: "token-audience-a"} - got, err := auth.GetAccessToken(ctx, provider, opts("audience-a")...) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(got).To(Equal(&mockToken{token: "token-audience-a"})) - - // A different audience must NOT reuse the first audience's cached token. - provider.returnControllerToken = &mockToken{token: "token-audience-b"} - got, err = auth.GetAccessToken(ctx, provider, opts("audience-b")...) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(got).To(Equal(&mockToken{token: "token-audience-b"})) - - // The first audience still returns its own cached token (per-audience cache); - // the refreshed controller token must not be minted. - provider.returnControllerToken = &mockToken{token: "token-audience-a-fresh"} - got, err = auth.GetAccessToken(ctx, provider, opts("audience-a")...) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(got).To(Equal(&mockToken{token: "token-audience-a"})) -} diff --git a/auth/aws/token.go b/auth/aws/credential.go similarity index 94% rename from auth/aws/token.go rename to auth/aws/credential.go index 7ef75fb2f..740757242 100644 --- a/auth/aws/token.go +++ b/auth/aws/credential.go @@ -24,7 +24,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/sts/types" ) -// Credentials is the AWS token. +// Credentials is the AWS credential. type Credentials struct{ types.Credentials } func newTokenFromAWSCredentials(creds *aws.Credentials) *Credentials { @@ -36,7 +36,7 @@ func newTokenFromAWSCredentials(creds *aws.Credentials) *Credentials { }} } -// GetDuration implements auth.Token. +// GetDuration implements auth.Credential. func (c *Credentials) GetDuration() time.Duration { return time.Until(*c.Expiration) } diff --git a/auth/aws/credentials_provider.go b/auth/aws/credentials_provider.go index c706e49cc..326461d6a 100644 --- a/auth/aws/credentials_provider.go +++ b/auth/aws/credentials_provider.go @@ -42,13 +42,13 @@ func NewCredentialsProvider(ctx context.Context, opts ...auth.Option) aws.Creden // the behavior of all providers around this so the usage of this // library can be consistent regardless of the provider. func (c *credentialsProvider) Retrieve(context.Context) (aws.Credentials, error) { - token, err := auth.GetAccessToken(c.ctx, Provider{}, c.opts...) + credential, err := auth.NewAccessCredential(c.ctx, Provider{}, c.opts...) if err != nil { return aws.Credentials{}, err } - awsCreds, ok := token.(*Credentials) + awsCreds, ok := credential.(*Credentials) if !ok { - return aws.Credentials{}, fmt.Errorf("failed to cast token to AWS token: %T", token) + return aws.Credentials{}, fmt.Errorf("failed to cast credential to AWS credential: %T", credential) } return aws.Credentials{ AccessKeyID: *awsCreds.AccessKeyId, diff --git a/auth/aws/provider.go b/auth/aws/provider.go index 766759113..824f5746c 100644 --- a/auth/aws/provider.go +++ b/auth/aws/provider.go @@ -69,7 +69,7 @@ func (Provider) GetName() string { } // NewControllerToken implements auth.Provider. -func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { +func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) @@ -124,9 +124,9 @@ func (Provider) GetIdentity(opts ...auth.Option) (string, error) { return o.AWSRoleARN, nil } -// NewTokenForServiceAccount implements auth.Provider. -func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, - opts ...auth.Option) (auth.Token, error) { +// NewAccessCredentialForIdentity implements auth.Provider. +func (p Provider) NewAccessCredentialForIdentity(ctx context.Context, identity auth.Credential, + opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) @@ -145,6 +145,11 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin roleARN := o.AWSRoleARN + jwtCredential, ok := identity.(*auth.JWTCredential) + if !ok { + return nil, fmt.Errorf("expected *auth.JWTCredential, got %T", identity) + } + sa := o.ServiceAccountRef() roleSessionName := cryptov1.GetAWSRoleSessionName(sa.Name, sa.Namespace, stsRegion) @@ -160,7 +165,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin req := &sts.AssumeRoleWithWebIdentityInput{ RoleArn: &roleARN, RoleSessionName: &roleSessionName, - WebIdentityToken: &oidcToken, + WebIdentityToken: &jwtCredential.Token, } resp, err := p.impl().AssumeRoleWithWebIdentity(ctx, req, stsOpts) if err != nil { @@ -178,8 +183,8 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin return creds, nil } -// GetAccessTokenOptionsForArtifactRepository implements auth.Provider. -func (p Provider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string, +// GetAccessCredentialOptionsForArtifactRepository implements auth.Provider. +func (p Provider) GetAccessCredentialOptionsForArtifactRepository(artifactRepository string, _ ...auth.Option) ([]auth.Option, error) { // AWS requires a region for getting access credentials. To avoid requiring // two regions to be passed in the Flux APIs we leverage the region present @@ -208,7 +213,7 @@ func (Provider) ParseArtifactRepository(artifactRepository string) (string, erro // NewArtifactRegistryCredentials implements auth.Provider. func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryInput string, - accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { + accessCredential auth.Credential, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { var o auth.Options o.Apply(opts...) @@ -220,7 +225,7 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryIn conf := aws.Config{ Region: cryptov1.GetAWSRegionFromArtifactRegistryInput(registryInput), - Credentials: accessToken.(*Credentials).provider(), + Credentials: accessCredential.(*Credentials).provider(), HTTPClient: o.GetHTTPClient(), } @@ -275,8 +280,8 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryIn }, nil } -// GetAccessTokenOptionsForCluster implements auth.Provider. -func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { +// GetAccessCredentialOptionsForCluster implements auth.Provider. +func (Provider) GetAccessCredentialOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { var o auth.Options o.Apply(opts...) if o.AWSRegion == "" { @@ -289,14 +294,14 @@ func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.O // // Reference: // https://docs.aws.amazon.com/eks/latest/best-practices/identity-and-access-management.html#_controlling_access_to_eks_clusters -func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, +func (p Provider) NewRESTConfig(ctx context.Context, accessCredentials []auth.Credential, opts ...auth.Option) (*auth.RESTConfig, error) { // The expiration for an EKS restconfig is always 15 minutes, see the reference above. // Let's record time.Now() on the beginning of the procedure to be on the safe side. expiresAt := time.Now().Add(15 * time.Minute) - creds := accessTokens[0].(*Credentials).provider() + creds := accessCredentials[0].(*Credentials).provider() var o auth.Options o.Apply(opts...) @@ -367,12 +372,12 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, }, nil } -// GetAccessTokenOptionsForGitRepository implements auth.GitCredentialsProvider. +// GetAccessCredentialOptionsForGitRepository implements auth.GitCredentialsProvider. // AWS requires a region for obtaining access credentials. To avoid requiring // callers to pass a region in addition to the CodeCommit URL, we extract the // region from the URL and inject it as AWSRegion so that object-level workload // identity (which requires an explicit region) works without extra config. -func (Provider) GetAccessTokenOptionsForGitRepository(gitURL *url.URL) ([]auth.Option, error) { +func (Provider) GetAccessCredentialOptionsForGitRepository(gitURL *url.URL) ([]auth.Option, error) { if gitURL == nil { return nil, fmt.Errorf("Git URL must be specified for AWS CodeCommit authentication") } @@ -396,7 +401,7 @@ func (Provider) ParseGitRepository(gitURL *url.URL) (string, error) { // NewGitCredentials implements auth.GitCredentialsProvider. func (Provider) NewGitCredentials(_ context.Context, gitInput string, - accessToken auth.Token, _ ...auth.Option) (*auth.GitCredentials, error) { + accessCredential auth.Credential, _ ...auth.Option) (*auth.GitCredentials, error) { gitURL, err := url.Parse(gitInput) if err != nil { @@ -407,9 +412,9 @@ func (Provider) NewGitCredentials(_ context.Context, gitInput string, return nil, err } - creds, ok := accessToken.(*Credentials) + creds, ok := accessCredential.(*Credentials) if !ok { - return nil, fmt.Errorf("failed to cast token to AWS token: %T", accessToken) + return nil, fmt.Errorf("failed to cast credential to AWS credential: %T", accessCredential) } req, err := http.NewRequest("GIT", gitURL.String(), nil) diff --git a/auth/aws/provider_test.go b/auth/aws/provider_test.go index e3490f735..e0b72f17f 100644 --- a/auth/aws/provider_test.go +++ b/auth/aws/provider_test.go @@ -30,9 +30,9 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" "github.com/fluxcd/pkg/auth" "github.com/fluxcd/pkg/auth/aws" - "github.com/fluxcd/pkg/auth/generic" ) func TestProvider_NewControllerToken(t *testing.T) { @@ -97,7 +97,7 @@ func TestProvider_NewControllerToken(t *testing.T) { } } -func TestProvider_NewTokenForServiceAccount(t *testing.T) { +func TestProvider_NewAccessCredentialForIdentity(t *testing.T) { for _, tt := range []struct { name string roleARN string @@ -160,7 +160,10 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) { } provider := aws.Provider{Implementation: impl} - token, err := provider.NewTokenForServiceAccount(context.Background(), oidcToken, opts...) + token, err := provider.NewAccessCredentialForIdentity(context.Background(), &auth.JWTCredential{ + Provider: cryptov1.CredentialProviderKubernetes, + Token: oidcToken, + }, opts...) if tt.err == "" { g.Expect(err).NotTo(HaveOccurred()) @@ -267,10 +270,10 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) { } } -func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForArtifactRepository(t *testing.T) { g := NewWithT(t) - opts, err := aws.Provider{}.GetAccessTokenOptionsForArtifactRepository( + opts, err := aws.Provider{}.GetAccessCredentialOptionsForArtifactRepository( "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1") g.Expect(err).NotTo(HaveOccurred()) @@ -547,11 +550,11 @@ func TestProvider_NewRESTConfig(t *testing.T) { } } -func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForCluster(t *testing.T) { t.Run("with region", func(t *testing.T) { g := NewWithT(t) - opts, err := aws.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := aws.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithAWSRegion("us-west-2"), auth.WithAWSClusterName("my-cluster")) g.Expect(err).NotTo(HaveOccurred()) @@ -566,7 +569,7 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { t.Run("without region", func(t *testing.T) { g := NewWithT(t) - opts, err := aws.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := aws.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithAWSClusterName("my-cluster")) g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(Equal("AWS region is required")) @@ -574,7 +577,7 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { }) } -func TestProvider_GetAccessTokenOptionsForGitRepository(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForGitRepository(t *testing.T) { for _, tt := range []struct { name string gitURL string @@ -619,15 +622,15 @@ func TestProvider_GetAccessTokenOptionsForGitRepository(t *testing.T) { parsedURL, err = url.Parse(tt.gitURL) g.Expect(err).NotTo(HaveOccurred()) } - atOpts, err := aws.Provider{}.GetAccessTokenOptionsForGitRepository(parsedURL) + acOpts, err := aws.Provider{}.GetAccessCredentialOptionsForGitRepository(parsedURL) if tt.err != "" { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(Equal(tt.err)) - g.Expect(atOpts).To(BeNil()) + g.Expect(acOpts).To(BeNil()) } else { g.Expect(err).NotTo(HaveOccurred()) var o auth.Options - o.Apply(atOpts...) + o.Apply(acOpts...) g.Expect(o.AWSRegion).To(Equal(tt.expectedRegion)) } }) @@ -693,41 +696,41 @@ func TestProvider_ParseGitRepository(t *testing.T) { } func TestProvider_NewGitCredentials(t *testing.T) { - invalidToken := &generic.Token{Token: "invalid", ExpiresAt: time.Now().Add(time.Hour)} + invalidCredential := &auth.RESTConfig{Host: "invalid"} proxyUrl := url.URL{Scheme: "http", Host: "proxy.example.com"} awsRegion := "us-east-1" for _, tt := range []struct { - name string - gitURL string - getAccessToken bool - accessToken auth.Token - expectedUsername string - err string + name string + gitURL string + getAccessCredential bool + accessCredential auth.Credential + expectedUsername string + err string }{ { - name: "valid CodeCommit URL", - gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo", - getAccessToken: true, - expectedUsername: "access-key-id%session-token", + name: "valid CodeCommit URL", + gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo", + getAccessCredential: true, + expectedUsername: "access-key-id%session-token", }, { - name: "valid CodeCommit FIPS URL", - gitURL: "https://git-codecommit-fips.us-east-1.amazonaws.com/v1/repos/test-repo", - getAccessToken: true, - expectedUsername: "access-key-id%session-token", + name: "valid CodeCommit FIPS URL", + gitURL: "https://git-codecommit-fips.us-east-1.amazonaws.com/v1/repos/test-repo", + getAccessCredential: true, + expectedUsername: "access-key-id%session-token", }, { - name: "valid CodeCommit China URL", - gitURL: "https://git-codecommit.cn-north-1.amazonaws.com.cn/v1/repos/test-repo", - getAccessToken: true, - expectedUsername: "access-key-id%session-token", + name: "valid CodeCommit China URL", + gitURL: "https://git-codecommit.cn-north-1.amazonaws.com.cn/v1/repos/test-repo", + getAccessCredential: true, + expectedUsername: "access-key-id%session-token", }, { - name: "invalid access token type", - gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo", - getAccessToken: false, - accessToken: invalidToken, - err: "failed to cast token to AWS token: *generic.Token", + name: "invalid access credential type", + gitURL: "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/test-repo", + getAccessCredential: false, + accessCredential: invalidCredential, + err: "failed to cast credential to AWS credential: *auth.RESTConfig", }, } { t.Run(tt.name, func(t *testing.T) { @@ -741,17 +744,17 @@ func TestProvider_NewGitCredentials(t *testing.T) { } provider := aws.Provider{Implementation: impl} - accessToken := tt.accessToken - if tt.getAccessToken { + accessCredential := tt.accessCredential + if tt.getAccessCredential { var err error - accessToken, err = auth.GetAccessToken(t.Context(), provider, + accessCredential, err = auth.NewAccessCredential(t.Context(), provider, auth.WithAWSRegion(awsRegion), auth.WithProxyURL(proxyUrl), ) g.Expect(err).NotTo(HaveOccurred()) } - creds, err := provider.NewGitCredentials(t.Context(), tt.gitURL, accessToken) + creds, err := provider.NewGitCredentials(t.Context(), tt.gitURL, accessCredential) if tt.err == "" { g.Expect(err).NotTo(HaveOccurred()) diff --git a/auth/azure/token.go b/auth/azure/credential.go similarity index 81% rename from auth/azure/token.go rename to auth/azure/credential.go index 8b39e2507..930664229 100644 --- a/auth/azure/token.go +++ b/auth/azure/credential.go @@ -24,17 +24,17 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" ) -// Token is the Azure token. -type Token struct{ azcore.AccessToken } +// AccessToken is the Azure access token. +type AccessToken struct{ azcore.AccessToken } type staticTokenCredential struct{ azcore.AccessToken } -// GetDuration implements auth.Token. -func (t *Token) GetDuration() time.Duration { +// GetDuration implements auth.Credential. +func (t *AccessToken) GetDuration() time.Duration { return time.Until(t.ExpiresOn) } -func (t *Token) credential() azcore.TokenCredential { +func (t *AccessToken) credential() azcore.TokenCredential { return &staticTokenCredential{t.AccessToken} } diff --git a/auth/azure/provider.go b/auth/azure/provider.go index 562cc9d77..25ce5ebc6 100644 --- a/auth/azure/provider.go +++ b/auth/azure/provider.go @@ -50,7 +50,7 @@ func (Provider) GetName() string { } // NewControllerToken implements auth.Provider. -func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { +func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) @@ -76,7 +76,7 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) ( return nil, err } - return &Token{token}, nil + return &AccessToken{token}, nil } // GetAudiences implements auth.Provider. @@ -96,13 +96,18 @@ func (Provider) GetIdentity(opts ...auth.Option) (string, error) { return fmt.Sprintf("%s/%s", o.AzureTenantID, o.AzureClientID), nil } -// NewTokenForServiceAccount implements auth.Provider. -func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, - opts ...auth.Option) (auth.Token, error) { +// NewAccessCredentialForIdentity implements auth.Provider. +func (p Provider) NewAccessCredentialForIdentity(ctx context.Context, identity auth.Credential, + opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) + jwtCredential, ok := identity.(*auth.JWTCredential) + if !ok { + return nil, fmt.Errorf("expected *auth.JWTCredential, got %T", identity) + } + tenantID := o.AzureTenantID clientID := o.AzureClientID @@ -113,7 +118,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin } cred, err := p.impl().NewClientAssertionCredential(tenantID, clientID, func(context.Context) (string, error) { - return oidcToken, nil + return jwtCredential.Token, nil }, azOpts) if err != nil { return nil, err @@ -125,13 +130,13 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin return nil, err } - return &Token{token}, nil + return &AccessToken{token}, nil } -// GetAccessTokenOptionsForArtifactRepository implements auth.Provider. -func (p Provider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string, +// GetAccessCredentialOptionsForArtifactRepository implements auth.Provider. +func (p Provider) GetAccessCredentialOptionsForArtifactRepository(artifactRepository string, _ ...auth.Option) ([]auth.Option, error) { - // Azure requires scopes for getting access tokens. Here we compute + // Azure requires scopes for getting access credentials. Here we compute // the scope for ACR, which is based on the registry host. registry, err := cryptov1.GetRegistryFromArtifactRepository(artifactRepository) @@ -196,7 +201,7 @@ func (Provider) ParseArtifactRepository(artifactRepository string) (string, erro // NewArtifactRegistryCredentials implements auth.Provider. func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registry string, - accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { + accessCredential auth.Credential, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { var o auth.Options o.Apply(opts...) @@ -213,11 +218,11 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registry s return nil, err } - // Exchange the access token for an ACR token. + // Exchange the access credential for an ACR token. grantType := azcontainerregistry.PostContentSchemaGrantTypeAccessToken service := registry tokenOpts := &azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions{ - AccessToken: &accessToken.(*Token).Token, + AccessToken: &accessCredential.(*AccessToken).Token, } resp, err := p.impl().ExchangeAADAccessTokenForACRRefreshToken(ctx, client, grantType, service, tokenOpts) if err != nil { @@ -246,17 +251,17 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registry s }, nil } -// GetAccessTokenOptionsForCluster implements auth.Provider. -func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { +// GetAccessCredentialOptionsForCluster implements auth.Provider. +func (Provider) GetAccessCredentialOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { var o auth.Options o.Apply(opts...) - var atOpts [][]auth.Option + var acOpts [][]auth.Option // Token used for impersonating the Managed Identity inside the AKS cluster. const aksScope = "6dae42f8-4368-4678-94ff-3960e28e3630/.default" aksTokenOpts := []auth.Option{auth.WithScopes(aksScope)} - atOpts = append(atOpts, aksTokenOpts) + acOpts = append(acOpts, aksTokenOpts) // Token needed for looking up details of the cluster resource. if o.ClusterAddress == "" || o.CAData == "" { @@ -275,21 +280,21 @@ func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.O } armScope := conf.Services[cloud.ResourceManager].Audience + "/.default" armTokenOpts := []auth.Option{auth.WithScopes(armScope)} - atOpts = append(atOpts, armTokenOpts) + acOpts = append(acOpts, armTokenOpts) } - return atOpts, nil + return acOpts, nil } // NewRESTConfig implements auth.Provider. -func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, +func (p Provider) NewRESTConfig(ctx context.Context, accessCredentials []auth.Credential, opts ...auth.Option) (*auth.RESTConfig, error) { - aksToken := accessTokens[0].(*Token) + aksToken := accessCredentials[0].(*AccessToken) - var armToken *Token - if len(accessTokens) == 2 { - armToken = accessTokens[1].(*Token) + var armToken *AccessToken + if len(accessCredentials) == 2 { + armToken = accessCredentials[1].(*AccessToken) } var o auth.Options @@ -391,15 +396,15 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, }, nil } -// GetAccessTokenOptionsForGitRepository implements auth.GitCredentialsProvider. -// Azure DevOps requires the DevOps scope for obtaining an access token that +// GetAccessCredentialOptionsForGitRepository implements auth.GitCredentialsProvider. +// Azure DevOps requires the DevOps scope for obtaining an access credential that // can be used as a bearer token against Azure Repos. -func (Provider) GetAccessTokenOptionsForGitRepository(*url.URL) ([]auth.Option, error) { +func (Provider) GetAccessCredentialOptionsForGitRepository(*url.URL) ([]auth.Option, error) { return []auth.Option{auth.WithScopes(ScopeDevOps)}, nil } // ParseGitRepository implements auth.GitCredentialsProvider. -// Azure DevOps access tokens are not bound to the Git repository URL, +// Azure DevOps access credentials are not bound to the Git repository URL, // so this returns a constant input that does not affect the cache key. func (Provider) ParseGitRepository(*url.URL) (string, error) { return "azure-devops", nil @@ -407,15 +412,15 @@ func (Provider) ParseGitRepository(*url.URL) (string, error) { // NewGitCredentials implements auth.GitCredentialsProvider. func (Provider) NewGitCredentials(_ context.Context, _ string, - accessToken auth.Token, _ ...auth.Option) (*auth.GitCredentials, error) { + accessCredential auth.Credential, _ ...auth.Option) (*auth.GitCredentials, error) { - token, ok := accessToken.(*Token) + credential, ok := accessCredential.(*AccessToken) if !ok { - return nil, fmt.Errorf("failed to cast token to Azure token: %T", accessToken) + return nil, fmt.Errorf("failed to cast credential to Azure access token: %T", accessCredential) } return &auth.GitCredentials{ - BearerToken: token.Token, - ExpiresAt: token.ExpiresOn, + BearerToken: credential.Token, + ExpiresAt: credential.ExpiresOn, }, nil } diff --git a/auth/azure/provider_test.go b/auth/azure/provider_test.go index 0c436640d..f16462650 100644 --- a/auth/azure/provider_test.go +++ b/auth/azure/provider_test.go @@ -32,6 +32,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" . "github.com/onsi/gomega" + cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" "github.com/fluxcd/pkg/auth" "github.com/fluxcd/pkg/auth/azure" ) @@ -71,9 +72,9 @@ func TestProvider_NewControllerToken(t *testing.T) { } provider := azure.Provider{Implementation: impl} - token, err := provider.NewControllerToken(context.Background(), opts...) + credential, err := provider.NewControllerToken(context.Background(), opts...) g.Expect(err).NotTo(HaveOccurred()) - g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{ + g.Expect(credential).To(Equal(&azure.AccessToken{AccessToken: azcore.AccessToken{ Token: "access-token", ExpiresOn: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), }})) @@ -81,7 +82,7 @@ func TestProvider_NewControllerToken(t *testing.T) { } } -func TestProvider_NewTokenForServiceAccount(t *testing.T) { +func TestProvider_NewAccessCredentialForIdentity(t *testing.T) { for _, tt := range []struct { name string tenantID string @@ -120,18 +121,21 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) { } provider := azure.Provider{Implementation: impl} - token, err := provider.NewTokenForServiceAccount(context.Background(), oidcToken, opts...) + credential, err := provider.NewAccessCredentialForIdentity(context.Background(), &auth.JWTCredential{ + Provider: cryptov1.CredentialProviderKubernetes, + Token: oidcToken, + }, opts...) if tt.err == "" { g.Expect(err).NotTo(HaveOccurred()) - g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{ + g.Expect(credential).To(Equal(&azure.AccessToken{AccessToken: azcore.AccessToken{ Token: "access-token", ExpiresOn: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), }})) } else { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(Equal(tt.err)) - g.Expect(token).To(BeNil()) + g.Expect(credential).To(BeNil()) } }) } @@ -302,7 +306,7 @@ func TestProvider_ParseArtifactRegistry(t *testing.T) { } } -func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForArtifactRepository(t *testing.T) { for _, tt := range []struct { name string artifactRepository string @@ -350,7 +354,7 @@ func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) { } provider := azure.Provider{} - opts, err := provider.GetAccessTokenOptionsForArtifactRepository(tt.artifactRepository) + opts, err := provider.GetAccessCredentialOptionsForArtifactRepository(tt.artifactRepository) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(1)) @@ -613,11 +617,11 @@ func TestProvider_NewRESTConfig(t *testing.T) { } } -func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForCluster(t *testing.T) { g := NewWithT(t) t.Run("needs to fetch cluster", func(t *testing.T) { - opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := azure.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithClusterResource("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster")) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(2)) @@ -642,7 +646,7 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { // Set the environment variable to point to the temp file t.Setenv("AZURE_ENVIRONMENT_FILEPATH", tempFileName) - opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := azure.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithClusterResource("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster")) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(2)) @@ -659,7 +663,7 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { }) t.Run("no need to fetch cluster", func(t *testing.T) { - opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := azure.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithClusterAddress("https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"), auth.WithCAData("-----BEGIN CERTIFICATE-----")) g.Expect(err).NotTo(HaveOccurred()) @@ -672,13 +676,13 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { }) } -func TestProvider_GetAccessTokenOptionsForGitRepository(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForGitRepository(t *testing.T) { g := NewWithT(t) gitURL, err := url.Parse("https://dev.azure.com/myorg/myproject/_git/myrepo") g.Expect(err).NotTo(HaveOccurred()) - opts, err := azure.Provider{}.GetAccessTokenOptionsForGitRepository(gitURL) + opts, err := azure.Provider{}.GetAccessCredentialOptionsForGitRepository(gitURL) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(1)) @@ -687,7 +691,7 @@ func TestProvider_GetAccessTokenOptionsForGitRepository(t *testing.T) { g.Expect(o.Scopes).To(Equal([]string{azure.ScopeDevOps})) // The URL is not used to compute the scope, so a nil URL must also work. - opts, err = azure.Provider{}.GetAccessTokenOptionsForGitRepository(nil) + opts, err = azure.Provider{}.GetAccessCredentialOptionsForGitRepository(nil) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(1)) o = auth.Options{} @@ -698,7 +702,7 @@ func TestProvider_GetAccessTokenOptionsForGitRepository(t *testing.T) { func TestProvider_ParseGitRepository(t *testing.T) { g := NewWithT(t) - // Azure DevOps access tokens are not bound to the repository URL, so + // Azure DevOps access credentials are not bound to the repository URL, so // ParseGitRepository must return a stable input regardless of the URL. for _, raw := range []string{ "https://dev.azure.com/myorg/myproject/_git/myrepo", @@ -723,14 +727,14 @@ func TestProvider_NewGitCredentials(t *testing.T) { expiresOn := time.Now().Add(time.Hour).UTC().Truncate(time.Second) for _, tt := range []struct { - name string - accessToken auth.Token - expected *auth.GitCredentials - err string + name string + accessCredential auth.Credential + expected *auth.GitCredentials + err string }{ { - name: "valid Azure access token is returned as bearer token", - accessToken: &azure.Token{AccessToken: azcore.AccessToken{ + name: "valid Azure access credential is returned as bearer token", + accessCredential: &azure.AccessToken{AccessToken: azcore.AccessToken{ Token: "aad-token", ExpiresOn: expiresOn, }}, @@ -740,16 +744,16 @@ func TestProvider_NewGitCredentials(t *testing.T) { }, }, { - name: "wrong token type is rejected", - accessToken: &mockNonAzureToken{}, - err: "failed to cast token to Azure token: *azure_test.mockNonAzureToken", + name: "wrong token type is rejected", + accessCredential: &mockNonAzureCredential{}, + err: "failed to cast credential to Azure access token: *azure_test.mockNonAzureCredential", }, } { t.Run(tt.name, func(t *testing.T) { g := NewWithT(t) creds, err := azure.Provider{}.NewGitCredentials( - context.Background(), "azure-devops", tt.accessToken) + context.Background(), "azure-devops", tt.accessCredential) if tt.err != "" { g.Expect(err).To(HaveOccurred()) @@ -763,9 +767,9 @@ func TestProvider_NewGitCredentials(t *testing.T) { } } -type mockNonAzureToken struct{} +type mockNonAzureCredential struct{} -func (*mockNonAzureToken) GetDuration() time.Duration { return 0 } +func (*mockNonAzureCredential) GetDuration() time.Duration { return 0 } func createKubeconfig(clusterName, serverURL string) []byte { return []byte(fmt.Sprintf(`apiVersion: v1 diff --git a/auth/azure/token_credential.go b/auth/azure/token_credential.go index f51c03aaa..229b20361 100644 --- a/auth/azure/token_credential.go +++ b/auth/azure/token_credential.go @@ -48,13 +48,13 @@ func (t *tokenCredential) GetToken(_ context.Context, tokenOpts policy.TokenRequ if tokenOpts.Scopes != nil { opts = append(opts, auth.WithScopes(tokenOpts.Scopes...)) } - token, err := auth.GetAccessToken(t.ctx, Provider{}, opts...) + credential, err := auth.NewAccessCredential(t.ctx, Provider{}, opts...) if err != nil { return azcore.AccessToken{}, err } - azureToken, ok := token.(*Token) + azureCredential, ok := credential.(*AccessToken) if !ok { - return azcore.AccessToken{}, fmt.Errorf("failed to cast token to Azure token: %T", token) + return azcore.AccessToken{}, fmt.Errorf("failed to cast credential to Azure credential: %T", credential) } - return azureToken.AccessToken, nil + return azureCredential.AccessToken, nil } diff --git a/auth/credential.go b/auth/credential.go new file mode 100644 index 000000000..61c2327f2 --- /dev/null +++ b/auth/credential.go @@ -0,0 +1,56 @@ +/* +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 auth + +import ( + "time" +) + +// Credential is an interface that represents a credential that can be used to +// authenticate requests for a service provider. The only common method is for +// getting the duration of the credential, because different providers have +// different ways of representing the credential. For example, Azure and GCP use +// a single string, while AWS uses three strings: access key ID, secret access +// key and token session. Consumers of this interface should know what type to +// cast it to. +type Credential interface { + // GetDuration returns the duration for which the credential will still be + // valid relative to approximately time.Now(). This is used to determine + // when the credential should be renewed. + GetDuration() time.Duration +} + +// JWTCredential is a credential represented by a JWT. It is used as the +// identity passed to providers that exchange JWTs for service provider +// credentials, and by providers that use the JWT directly as the access +// credential. +type JWTCredential struct { + // Provider is the credential provider that minted the JWT, e.g. + // `kubernetes` or `spiffe`. + Provider string + // Token is the serialized JWT. + Token string + // ExpiresAt is the expiration time extracted from the JWT. + ExpiresAt time.Time +} + +var _ Credential = (*JWTCredential)(nil) + +// GetDuration implements Credential. +func (c *JWTCredential) GetDuration() time.Duration { + return time.Until(c.ExpiresAt) +} diff --git a/auth/token_test.go b/auth/credential_test.go similarity index 88% rename from auth/token_test.go rename to auth/credential_test.go index a444a08d2..a44ce411a 100644 --- a/auth/token_test.go +++ b/auth/credential_test.go @@ -20,10 +20,10 @@ import ( "time" ) -type mockToken struct { +type mockCredential struct { token string } -func (m *mockToken) GetDuration() time.Duration { +func (m *mockCredential) GetDuration() time.Duration { return time.Hour } diff --git a/auth/gcp/token.go b/auth/gcp/credential.go similarity index 75% rename from auth/gcp/token.go rename to auth/gcp/credential.go index 6b77c010c..982cb256d 100644 --- a/auth/gcp/token.go +++ b/auth/gcp/credential.go @@ -22,14 +22,14 @@ import ( "golang.org/x/oauth2" ) -// Token is the GCP token. -type Token struct{ oauth2.Token } +// AccessToken is the GCP access token. +type AccessToken struct{ oauth2.Token } -// GetDuration implements auth.Token. -func (t *Token) GetDuration() time.Duration { +// GetDuration implements auth.Credential. +func (t *AccessToken) GetDuration() time.Duration { return time.Until(t.Expiry) } -func (t *Token) source() oauth2.TokenSource { +func (t *AccessToken) source() oauth2.TokenSource { return oauth2.StaticTokenSource(&t.Token) } diff --git a/auth/gcp/provider.go b/auth/gcp/provider.go index a11ad710a..b79efef5d 100644 --- a/auth/gcp/provider.go +++ b/auth/gcp/provider.go @@ -19,6 +19,7 @@ package gcp import ( "context" "encoding/base64" + "errors" "fmt" "net/http" @@ -50,7 +51,7 @@ func (Provider) GetName() string { } // NewControllerToken implements auth.Provider. -func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { +func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) @@ -65,7 +66,7 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) ( return nil, err } - return &Token{*token}, nil + return &AccessToken{*token}, nil } // GetAudiences implements auth.Provider. @@ -79,6 +80,9 @@ func (p Provider) GetAudiences(ctx context.Context, opts ...auth.Option) ([]stri // a single specific audience. This audience can be computed from information // provided by the GKE metadata server. if o.GCPWorkloadIdentityProvider == "" { + if len(o.CredentialAudiences) > 0 { + return nil, errors.New("cannot specify custom audiences for GKE workload identity") + } audience, err := gkeMetadata.getTokenAudience(ctx) if err != nil { return nil, err @@ -101,13 +105,18 @@ func (Provider) GetIdentity(opts ...auth.Option) (string, error) { return o.GCPServiceAccount, nil } -// NewTokenForServiceAccount implements auth.Provider. -func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, - opts ...auth.Option) (auth.Token, error) { +// NewAccessCredentialForIdentity implements auth.Provider. +func (p Provider) NewAccessCredentialForIdentity(ctx context.Context, identity auth.Credential, + opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) + jwtCredential, ok := identity.(*auth.JWTCredential) + if !ok { + return nil, fmt.Errorf("expected *auth.JWTCredential, got %T", identity) + } + // The STS exchange audience identifies the workload identity provider. It is // intentionally not derived from the credential audiences option: the latter // is the audience for the OIDC token, which the provider validates against @@ -139,7 +148,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin Audience: exchangeAudience, SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", TokenURL: "https://sts.googleapis.com/v1/token", - SubjectTokenSupplier: StaticTokenSupplier(oidcToken), + SubjectTokenSupplier: StaticTokenSupplier(jwtCredential.Token), Scopes: scopes, } @@ -164,12 +173,12 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin return nil, err } - return &Token{*token}, nil + return &AccessToken{*token}, nil } -// GetAccessTokenOptionsForArtifactRepository implements auth.Provider. -func (Provider) GetAccessTokenOptionsForArtifactRepository(string, ...auth.Option) ([]auth.Option, error) { - // GCP does not require any special options to retrieve access tokens. +// GetAccessCredentialOptionsForArtifactRepository implements auth.Provider. +func (Provider) GetAccessCredentialOptionsForArtifactRepository(string, ...auth.Option) ([]auth.Option, error) { + // GCP does not require any special options to retrieve access credentials. return nil, nil } @@ -180,30 +189,30 @@ func (Provider) ParseArtifactRepository(artifactRepository string) (string, erro // NewArtifactRegistryCredentials implements auth.Provider. func (Provider) NewArtifactRegistryCredentials(_ context.Context, _ string, - accessToken auth.Token, _ ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { + accessCredential auth.Credential, _ ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { - t := accessToken.(*Token) + credential := accessCredential.(*AccessToken) return &auth.ArtifactRegistryCredentials{ Authenticator: authn.FromConfig(authn.AuthConfig{ Username: "oauth2accesstoken", - Password: t.AccessToken, + Password: credential.AccessToken, }), - ExpiresAt: t.Expiry, + ExpiresAt: credential.Expiry, }, nil } -// GetAccessTokenOptionsForCluster implements auth.Provider. -func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { +// GetAccessCredentialOptionsForCluster implements auth.Provider. +func (Provider) GetAccessCredentialOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { // A single token is needed. No options. return [][]auth.Option{{}}, nil } // NewRESTConfig implements auth.Provider. -func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, +func (p Provider) NewRESTConfig(ctx context.Context, accessCredentials []auth.Credential, opts ...auth.Option) (*auth.RESTConfig, error) { - token := accessTokens[0].(*Token) + credential := accessCredentials[0].(*AccessToken) var o auth.Options o.Apply(opts...) @@ -219,7 +228,7 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, if p := o.ProxyURL; p != nil { baseTransport.Proxy = http.ProxyURL(p) } - transport, err := htransport.NewTransport(ctx, baseTransport, option.WithTokenSource(token.source())) + transport, err := htransport.NewTransport(ctx, baseTransport, option.WithTokenSource(credential.source())) if err != nil { return nil, fmt.Errorf("failed to create google http transport for describing GKE cluster: %w", err) } @@ -249,9 +258,9 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, // Build and return the REST config. return &auth.RESTConfig{ Host: host, - BearerToken: token.AccessToken, + BearerToken: credential.AccessToken, CAData: caData, - ExpiresAt: token.Expiry, + ExpiresAt: credential.Expiry, }, nil } diff --git a/auth/gcp/provider_test.go b/auth/gcp/provider_test.go index 2649c1f3c..3594a0330 100644 --- a/auth/gcp/provider_test.go +++ b/auth/gcp/provider_test.go @@ -47,12 +47,12 @@ func TestProvider_NewControllerToken(t *testing.T) { } provider := gcp.Provider{Implementation: impl} - token, err := provider.NewControllerToken(context.Background(), opts...) + credential, err := provider.NewControllerToken(context.Background(), opts...) g.Expect(err).NotTo(HaveOccurred()) - g.Expect(token).To(Equal(&gcp.Token{oauth2.Token{AccessToken: "access-token"}})) + g.Expect(credential).To(Equal(&gcp.AccessToken{oauth2.Token{AccessToken: "access-token"}})) } -func TestProvider_NewTokenForServiceAccount(t *testing.T) { +func TestProvider_NewAccessCredentialForIdentity(t *testing.T) { startGKEMetadataServer(t) for _, tt := range []struct { @@ -149,15 +149,18 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) { } provider := gcp.Provider{Implementation: impl} - token, err := provider.NewTokenForServiceAccount(context.Background(), oidcToken, opts...) + credential, err := provider.NewAccessCredentialForIdentity(context.Background(), &auth.JWTCredential{ + Provider: cryptov1.CredentialProviderKubernetes, + Token: oidcToken, + }, opts...) if tt.err == "" { g.Expect(err).NotTo(HaveOccurred()) - g.Expect(token).To(Equal(&gcp.Token{oauth2.Token{AccessToken: "access-token"}})) + g.Expect(credential).To(Equal(&gcp.AccessToken{oauth2.Token{AccessToken: "access-token"}})) } else { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(Equal(tt.err)) - g.Expect(token).To(BeNil()) + g.Expect(credential).To(BeNil()) } }) } @@ -173,6 +176,7 @@ func TestProvider_GetAudience(t *testing.T) { wip string credentialAudience string expected string + err string }{ { name: "federation", @@ -190,9 +194,10 @@ func TestProvider_GetAudience(t *testing.T) { expected: "project-id.svc.id.goog", }, { - name: "gke with custom audience ignored", + name: "gke with custom audience rejected", credentialAudience: "my-audience", expected: "project-id.svc.id.goog", + err: "cannot specify custom audiences for GKE workload identity", }, } { t.Run(tt.name, func(t *testing.T) { @@ -207,8 +212,13 @@ func TestProvider_GetAudience(t *testing.T) { } aud, err := gcp.Provider{}.GetAudiences(context.Background(), opts...) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(aud).To(Equal([]string{tt.expected})) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(aud).To(Equal([]string{tt.expected})) + } }) } } @@ -447,11 +457,11 @@ func TestProvider_NewRESTConfig(t *testing.T) { } } -func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForCluster(t *testing.T) { g := NewWithT(t) t.Run("with cluster resource", func(t *testing.T) { - opts, err := gcp.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := gcp.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithClusterResource("projects/test-project/locations/us-central1/clusters/test-cluster")) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(1)) @@ -459,7 +469,7 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { }) t.Run("without cluster resource", func(t *testing.T) { - opts, err := gcp.Provider{}.GetAccessTokenOptionsForCluster() + opts, err := gcp.Provider{}.GetAccessCredentialOptionsForCluster() g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(1)) g.Expect(opts[0]).To(HaveLen(0)) // Empty slice - no options needed for GCP diff --git a/auth/gcp/token_source.go b/auth/gcp/token_source.go index 5f0fdb86d..f303fb89e 100644 --- a/auth/gcp/token_source.go +++ b/auth/gcp/token_source.go @@ -37,13 +37,13 @@ func NewTokenSource(ctx context.Context, opts ...auth.Option) oauth2.TokenSource // Token implements oauth2.TokenSource. func (t *tokenSource) Token() (*oauth2.Token, error) { - token, err := auth.GetAccessToken(t.ctx, Provider{}, t.opts...) + credential, err := auth.NewAccessCredential(t.ctx, Provider{}, t.opts...) if err != nil { return nil, err } - gcpToken, ok := token.(*Token) + gcpCredential, ok := credential.(*AccessToken) if !ok { - return nil, fmt.Errorf("failed to cast token to GCP token: %T", token) + return nil, fmt.Errorf("failed to cast credential to GCP credential: %T", credential) } - return &gcpToken.Token, nil + return &gcpCredential.Token, nil } diff --git a/auth/generic/provider.go b/auth/generic/provider.go index b90f8d87f..f237e2174 100644 --- a/auth/generic/provider.go +++ b/auth/generic/provider.go @@ -42,7 +42,7 @@ func (Provider) GetName() string { } // NewControllerToken implements auth.RESTConfigProvider. -func (Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { +func (Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Credential, error) { var o auth.Options o.Apply(opts...) @@ -67,7 +67,8 @@ func (Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (au return nil, err } - return &Token{ + return &auth.JWTCredential{ + Provider: cryptov1.CredentialProviderKubernetes, Token: token, ExpiresAt: *exp, }, nil @@ -88,24 +89,31 @@ func (Provider) GetIdentity(opts ...auth.Option) (string, error) { return fmt.Sprintf("system:serviceaccount:%s:%s", sa.Namespace, sa.Name), nil } -// NewTokenForServiceAccount implements auth.RESTConfigProvider. -func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, - opts ...auth.Option) (auth.Token, error) { +// NewAccessCredentialForIdentity implements auth.RESTConfigProvider. +func (Provider) NewAccessCredentialForIdentity(_ context.Context, identity auth.Credential, + _ ...auth.Option) (auth.Credential, error) { - exp, err := getExpirationFromToken(oidcToken) - if err != nil { - return nil, err + credential, ok := identity.(*auth.JWTCredential) + if !ok { + return nil, fmt.Errorf("expected *auth.JWTCredential, got %T", identity) } - return &Token{ - Token: oidcToken, - ExpiresAt: *exp, - }, nil + // SPIFFE signing keys rotate frequently, and a JWT-SVID can stop being + // cryptographically verifiable before its expiration. Disable caching by + // zeroing the expiration time. SPIFFE runtimes normally cache credentials + // themselves. + if credential.Provider == cryptov1.CredentialProviderSPIFFE { + clone := *credential + clone.ExpiresAt = time.Time{} + return &clone, nil + } + + return credential, nil } -// GetAccessTokenOptionsForArtifactRepository implements +// GetAccessCredentialOptionsForArtifactRepository implements // auth.ArtifactRegistryCredentialsProvider. -func (Provider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string, +func (Provider) GetAccessCredentialOptionsForArtifactRepository(artifactRepository string, opts ...auth.Option) ([]auth.Option, error) { var o auth.Options @@ -134,12 +142,12 @@ func (Provider) ParseArtifactRepository(string) (string, error) { // NewArtifactRegistryCredentials implements // auth.ArtifactRegistryCredentialsProvider. func (Provider) NewArtifactRegistryCredentials(_ context.Context, _ string, - accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { + accessCredential auth.Credential, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { var o auth.Options o.Apply(opts...) - token := accessToken.(*Token) + credential := accessCredential.(*auth.JWTCredential) // Authenticate with the token as the password and the configured username // when one is provided, otherwise use the token as a bearer token. @@ -147,20 +155,20 @@ func (Provider) NewArtifactRegistryCredentials(_ context.Context, _ string, if o.CredentialUsername != "" { authenticator = authn.FromConfig(authn.AuthConfig{ Username: o.CredentialUsername, - Password: token.Token, + Password: credential.Token, }) } else { - authenticator = &authn.Bearer{Token: token.Token} + authenticator = &authn.Bearer{Token: credential.Token} } return &auth.ArtifactRegistryCredentials{ Authenticator: authenticator, - ExpiresAt: token.ExpiresAt, + ExpiresAt: credential.ExpiresAt, }, nil } -// GetAccessTokenOptionsForCluster implements auth.RESTConfigProvider. -func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { +// GetAccessCredentialOptionsForCluster implements auth.RESTConfigProvider. +func (Provider) GetAccessCredentialOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { var o auth.Options o.Apply(opts...) @@ -175,10 +183,10 @@ func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.O } // NewRESTConfig implements auth.RESTConfigProvider. -func (Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, +func (Provider) NewRESTConfig(ctx context.Context, accessCredentials []auth.Credential, opts ...auth.Option) (*auth.RESTConfig, error) { - token := accessTokens[0].(*Token) + credential := accessCredentials[0].(*auth.JWTCredential) var o auth.Options o.Apply(opts...) @@ -203,8 +211,8 @@ func (Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, return &auth.RESTConfig{ Host: host, CAData: caData, - BearerToken: token.Token, - ExpiresAt: token.ExpiresAt, + BearerToken: credential.Token, + ExpiresAt: credential.ExpiresAt, }, nil } diff --git a/auth/generic/provider_test.go b/auth/generic/provider_test.go index 8700308fb..414ded88c 100644 --- a/auth/generic/provider_test.go +++ b/auth/generic/provider_test.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" "github.com/fluxcd/pkg/auth" "github.com/fluxcd/pkg/auth/generic" ) @@ -39,7 +40,7 @@ func TestProvider_NewControllerToken(t *testing.T) { g.Expect(token).To(BeNil()) } -func TestProvider_NewTokenForServiceAccount(t *testing.T) { +func TestProvider_NewAccessCredentialForIdentity(t *testing.T) { signToken := func(t *testing.T, claims jwt.MapClaims) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) @@ -53,23 +54,44 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) { expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) validToken := signToken(t, jwt.MapClaims{"exp": expiresAt.Unix()}) - t.Run("valid token", func(t *testing.T) { + t.Run("kubernetes identity", func(t *testing.T) { g := NewWithT(t) - token, err := generic.Provider{}.NewTokenForServiceAccount(context.Background(), validToken) + identity := &auth.JWTCredential{ + Provider: cryptov1.CredentialProviderKubernetes, + Token: validToken, + ExpiresAt: expiresAt, + } + credential, err := generic.Provider{}.NewAccessCredentialForIdentity(context.Background(), identity) g.Expect(err).NotTo(HaveOccurred()) - g.Expect(token).To(Equal(&generic.Token{ + g.Expect(credential).To(Equal(identity)) + }) + + t.Run("spiffe identity disables caching", func(t *testing.T) { + g := NewWithT(t) + + identity := &auth.JWTCredential{ + Provider: cryptov1.CredentialProviderSPIFFE, Token: validToken, ExpiresAt: expiresAt, + } + credential, err := generic.Provider{}.NewAccessCredentialForIdentity(context.Background(), identity) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(credential).NotTo(BeIdenticalTo(identity)) + g.Expect(credential).To(Equal(&auth.JWTCredential{ + Provider: cryptov1.CredentialProviderSPIFFE, + Token: validToken, })) + g.Expect(identity.ExpiresAt).To(Equal(expiresAt)) }) - t.Run("invalid token", func(t *testing.T) { + t.Run("invalid identity type", func(t *testing.T) { g := NewWithT(t) - token, err := generic.Provider{}.NewTokenForServiceAccount(context.Background(), "not-a-token") - g.Expect(err).To(MatchError(ContainSubstring("failed to parse service account token"))) - g.Expect(token).To(BeNil()) + credential, err := generic.Provider{}.NewAccessCredentialForIdentity( + context.Background(), &auth.RESTConfig{}) + g.Expect(err).To(MatchError(ContainSubstring("expected *auth.JWTCredential"))) + g.Expect(credential).To(BeNil()) }) } @@ -81,11 +103,11 @@ func TestProvider_GetAudiences(t *testing.T) { g.Expect(audiences).To(ConsistOf("audience1", "audience2")) } -func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForArtifactRepository(t *testing.T) { t.Run("registry host is the default audience", func(t *testing.T) { g := NewWithT(t) - opts, err := generic.Provider{}.GetAccessTokenOptionsForArtifactRepository( + opts, err := generic.Provider{}.GetAccessCredentialOptionsForArtifactRepository( "my-zot-registry.com/owner/repo") g.Expect(err).NotTo(HaveOccurred()) @@ -97,7 +119,7 @@ func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) { t.Run("explicit audiences are not overridden", func(t *testing.T) { g := NewWithT(t) - opts, err := generic.Provider{}.GetAccessTokenOptionsForArtifactRepository( + opts, err := generic.Provider{}.GetAccessCredentialOptionsForArtifactRepository( "my-zot-registry.com/owner/repo", auth.WithCredentialAudiences("audience1", "audience2")) g.Expect(err).NotTo(HaveOccurred()) @@ -110,7 +132,7 @@ func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) { t.Run("invalid artifact repository", func(t *testing.T) { g := NewWithT(t) - opts, err := generic.Provider{}.GetAccessTokenOptionsForArtifactRepository( + opts, err := generic.Provider{}.GetAccessCredentialOptionsForArtifactRepository( "registry.io/OWNER/repo") g.Expect(err).To(MatchError(ContainSubstring("failed to parse artifact repository"))) g.Expect(opts).To(BeNil()) @@ -135,7 +157,7 @@ func TestProvider_ParseArtifactRepository(t *testing.T) { func TestProvider_NewArtifactRegistryCredentials(t *testing.T) { expiresAt := time.Now().Add(time.Hour) - token := &generic.Token{ + credential := &auth.JWTCredential{ Token: "test-token", ExpiresAt: expiresAt, } @@ -144,7 +166,7 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) { g := NewWithT(t) creds, err := generic.Provider{}.NewArtifactRegistryCredentials( - context.Background(), "any-registry.io", token) + context.Background(), "any-registry.io", credential) g.Expect(err).NotTo(HaveOccurred()) g.Expect(creds).To(Equal(&auth.ArtifactRegistryCredentials{ Authenticator: &authn.Bearer{Token: "test-token"}, @@ -156,7 +178,7 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) { g := NewWithT(t) creds, err := generic.Provider{}.NewArtifactRegistryCredentials( - context.Background(), "any-registry.io", token, + context.Background(), "any-registry.io", credential, auth.WithCredentialUsername("user")) g.Expect(err).NotTo(HaveOccurred()) g.Expect(creds).To(Equal(&auth.ArtifactRegistryCredentials{ @@ -183,7 +205,7 @@ func TestProvider_GetIdentity(t *testing.T) { func TestProvider_NewRESTConfig(t *testing.T) { expiresAt := time.Now().Add(time.Hour) - accessToken := &generic.Token{ + accessCredential := &auth.JWTCredential{ Token: "token", ExpiresAt: expiresAt, } @@ -225,7 +247,7 @@ func TestProvider_NewRESTConfig(t *testing.T) { } conf, err := generic.Provider{}.NewRESTConfig(context.Background(), - []auth.Token{accessToken}, opts...) + []auth.Credential{accessCredential}, opts...) if tt.err != "" { g.Expect(err).To(HaveOccurred()) @@ -245,10 +267,10 @@ func TestProvider_NewRESTConfig(t *testing.T) { } } -func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { +func TestProvider_GetAccessCredentialOptionsForCluster(t *testing.T) { t.Run("without audiences", func(t *testing.T) { g := NewWithT(t) - opts, err := generic.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := generic.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithClusterAddress("https://example.com")) g.Expect(err).NotTo(HaveOccurred()) g.Expect(opts).To(HaveLen(1)) @@ -260,7 +282,7 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) { t.Run("with audiences", func(t *testing.T) { g := NewWithT(t) - opts, err := generic.Provider{}.GetAccessTokenOptionsForCluster( + opts, err := generic.Provider{}.GetAccessCredentialOptionsForCluster( auth.WithClusterAddress("https://example.com"), auth.WithCredentialAudiences("audience1", "audience2")) g.Expect(err).NotTo(HaveOccurred()) diff --git a/auth/generic/token.go b/auth/generic/token.go deleted file mode 100644 index ecfb5d5cb..000000000 --- a/auth/generic/token.go +++ /dev/null @@ -1,32 +0,0 @@ -/* -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 generic - -import ( - "time" -) - -// Token is the generic token. -type Token struct { - Token string - ExpiresAt time.Time -} - -// GetDuration implements auth.Token. -func (t *Token) GetDuration() time.Duration { - return time.Until(t.ExpiresAt) -} diff --git a/auth/git.go b/auth/git.go index 7013760d8..e1c071c3c 100644 --- a/auth/git.go +++ b/auth/git.go @@ -33,10 +33,10 @@ import ( type GitCredentialsProvider interface { Provider - // GetAccessTokenOptionsForGitRepository returns the options that must be - // passed to the provider to retrieve access tokens for the given Git + // GetAccessCredentialOptionsForGitRepository returns the options that must be + // passed to the provider to retrieve access credentials for the given Git // repository URL. - GetAccessTokenOptionsForGitRepository(gitURL *url.URL) ([]Option, error) + GetAccessCredentialOptionsForGitRepository(gitURL *url.URL) ([]Option, error) // ParseGitRepository parses the given Git repository URL to verify // it's a valid repository URL for the provider. As a result it returns @@ -48,10 +48,10 @@ type GitCredentialsProvider interface { ParseGitRepository(gitURL *url.URL) (string, error) // NewGitCredentials takes the input extracted by ParseGitRepository() - // and an access token and returns credentials that can be used to + // and an access credential and returns credentials that can be used to // authenticate against the Git repository. NewGitCredentials(ctx context.Context, gitInput string, - accessToken Token, opts ...Option) (*GitCredentials, error) + accessCredential Credential, opts ...Option) (*GitCredentials, error) } // GitCredentials contains authentication data needed in order to access a Git @@ -63,7 +63,7 @@ type GitCredentials struct { ExpiresAt time.Time } -// GetDuration implements Token. +// GetDuration implements Credential. func (g *GitCredentials) GetDuration() time.Duration { return time.Until(g.ExpiresAt) } @@ -86,21 +86,21 @@ func GetGitCredentials(ctx context.Context, provider GitCredentialsProvider, return nil, err } - // First, we need an access token. This cannot be retrieved inside the - // cache lock, otherwise we reach a deadlock. - accessTokenOpts, err := provider.GetAccessTokenOptionsForGitRepository(gitURL) + // First, we need an access credential. This cannot be retrieved inside + // the cache lock, otherwise we reach a deadlock. + accessCredentialOpts, err := provider.GetAccessCredentialOptionsForGitRepository(gitURL) if err != nil { return nil, err } - accessTokenOpts = append(slices.Clone(opts), accessTokenOpts...) - accessToken, err := GetAccessToken(ctx, provider, accessTokenOpts...) + accessCredentialOpts = append(slices.Clone(opts), accessCredentialOpts...) + accessCredential, err := NewAccessCredential(ctx, provider, accessCredentialOpts...) if err != nil { - return nil, fmt.Errorf("failed to get access token for Git repository: %w", err) + return nil, fmt.Errorf("failed to get access credential for Git repository: %w", err) } // Prepare a function to create new credentials. newGitCredentials := func() (*GitCredentials, error) { - creds, err := provider.NewGitCredentials(ctx, gitInput, accessToken, opts...) + creds, err := provider.NewGitCredentials(ctx, gitInput, accessCredential, opts...) if err != nil { return nil, fmt.Errorf("failed to create Git credentials: %w", err) } @@ -116,7 +116,7 @@ func GetGitCredentials(ctx context.Context, provider GitCredentialsProvider, var serviceAccount *kubernetes.ServiceAccountReference var providerIdentity string var audiences []string - if o.ObjectLevelWorkloadIdentity() { + if o.objectLevelWorkloadIdentity() { var err error serviceAccount, audiences, providerIdentity, err = getServiceAccountAndProviderInfo(ctx, provider, opts...) @@ -124,10 +124,10 @@ func GetGitCredentials(ctx context.Context, provider GitCredentialsProvider, return nil, err } } - accessTokenCacheKey := buildAccessTokenCacheKey(provider, audiences, - providerIdentity, serviceAccount, accessTokenOpts...) + accessCredentialCacheKey := buildAccessCredentialCacheKey(provider, audiences, + providerIdentity, serviceAccount, accessCredentialOpts...) cacheKey := buildCacheKey( - fmt.Sprintf("accessTokenCacheKey=%s", accessTokenCacheKey), + fmt.Sprintf("accessCredentialCacheKey=%s", accessCredentialCacheKey), fmt.Sprintf("gitRepositoryCacheKey=%s", gitInput)) // Get credentials from cache. diff --git a/auth/git_test.go b/auth/git_test.go index ee631beec..f8f816b0e 100644 --- a/auth/git_test.go +++ b/auth/git_test.go @@ -63,15 +63,15 @@ func TestGetGitCredentials(t *testing.T) { expectedErr string }{ { - name: "git credentials from controller access token", + name: "git credentials from controller access credential", provider: &mockProvider{ - returnGitInput: "git-cache-key", - returnControllerToken: &mockToken{token: "mock-default-token"}, + returnGitInput: "git-cache-key", + returnControllerCredential: &mockCredential{token: "mock-default-token"}, returnGitCredentials: &auth.GitCredentials{ BearerToken: "mock-bearer-token", }, - paramGitURL: gitURL, - paramAccessToken: &mockToken{token: "mock-default-token"}, + paramGitURL: gitURL, + paramAccessCredential: &mockCredential{token: "mock-default-token"}, }, gitURL: gitURL, opts: []auth.Option{ @@ -87,20 +87,20 @@ func TestGetGitCredentials(t *testing.T) { }, }, { - name: "git credentials from access token from service account", + name: "git credentials from access credential from service account", provider: &mockProvider{ - returnName: "mock-provider", - returnGitInput: "git-cache-key", - returnAccessToken: &mockToken{token: "mock-access-token"}, + returnName: "mock-provider", + returnGitInput: "git-cache-key", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, returnGitCredentials: &auth.GitCredentials{ Username: "user", Password: "pass", }, - paramAudiences: []string{"audience1", "audience2"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - paramGitURL: gitURL, - paramAccessToken: &mockToken{token: "mock-access-token"}, + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + paramGitURL: gitURL, + paramAccessCredential: &mockCredential{token: "mock-access-token"}, }, gitURL: gitURL, opts: []auth.Option{ @@ -120,20 +120,20 @@ func TestGetGitCredentials(t *testing.T) { }, }, { - name: "git credentials from access token from explicit service account", + name: "git credentials from access credential from explicit service account", provider: &mockProvider{ - returnName: "mock-provider", - returnGitInput: "git-cache-key", - returnAccessToken: &mockToken{token: "mock-access-token"}, + returnName: "mock-provider", + returnGitInput: "git-cache-key", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, returnGitCredentials: &auth.GitCredentials{ Username: "user", Password: "pass", }, - paramAudiences: []string{"audience1", "audience2"}, - paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, - paramOIDCTokenClient: oidcClient, - paramGitURL: gitURL, - paramAccessToken: &mockToken{token: "mock-access-token"}, + paramAudiences: []string{"audience1", "audience2"}, + paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, + paramOIDCTokenClient: oidcClient, + paramGitURL: gitURL, + paramAccessCredential: &mockCredential{token: "mock-access-token"}, }, gitURL: gitURL, opts: []auth.Option{ @@ -176,27 +176,27 @@ func TestGetGitCredentials(t *testing.T) { tokenCache, err := cache.NewTokenCache(2) g.Expect(err).NotTo(HaveOccurred()) - const accessTokenKey = "6c016e84cc74ad7bf6bd8770cdb6a2efca3e185fdf90badd985c77984784f5c3" - var token auth.Token = &mockToken{token: "cached-token"} - cachedToken, ok, err := tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + const accessCredentialKey = "6c016e84cc74ad7bf6bd8770cdb6a2efca3e185fdf90badd985c77984784f5c3" + var credential auth.Credential = &mockCredential{token: "cached-token"} + cachedCredential, ok, err := tokenCache.GetOrSet(ctx, accessCredentialKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) - const gitCredentialsKey = "f78ca5404e02a7b139ceb6b925ac52344cb77f33e92197b153b9d87615e6b91b" - token = &auth.GitCredentials{ + const gitCredentialsKey = "cfd18224b55020209fdff7f11efcdc2e73a57f791ea4bc33189c3ddb91182181" + credential = &auth.GitCredentials{ Username: "cached-user", Password: "cached-pass", ExpiresAt: now.Add(time.Hour), } - cachedToken, ok, err = tokenCache.GetOrSet(ctx, gitCredentialsKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + cachedCredential, ok, err = tokenCache.GetOrSet(ctx, gitCredentialsKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) o.Cache = tokenCache }, diff --git a/auth/go.mod b/auth/go.mod index 4120a46e9..e7a093548 100644 --- a/auth/go.mod +++ b/auth/go.mod @@ -6,6 +6,7 @@ replace ( github.com/fluxcd/pkg/apis/crypto => ../apis/crypto github.com/fluxcd/pkg/apis/meta => ../apis/meta github.com/fluxcd/pkg/cache => ../cache + github.com/fluxcd/pkg/spiffe => ../spiffe github.com/fluxcd/pkg/ssh => ../ssh ) @@ -27,6 +28,7 @@ require ( github.com/fluxcd/pkg/apis/crypto v0.4.0 github.com/fluxcd/pkg/apis/meta v1.36.0 github.com/fluxcd/pkg/cache v0.17.0 + github.com/fluxcd/pkg/spiffe v0.0.0 github.com/fluxcd/pkg/ssh v0.28.0 github.com/go-jose/go-jose/v4 v4.1.5 github.com/golang-jwt/jwt/v4 v4.5.2 @@ -35,6 +37,7 @@ require ( github.com/google/go-containerregistry v0.22.1 github.com/google/go-github/v87 v87.0.0 github.com/onsi/gomega v1.43.0 + github.com/spiffe/go-spiffe/v2 v2.9.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 golang.org/x/net v0.61.0 golang.org/x/oauth2 v0.37.0 @@ -85,6 +88,7 @@ require ( github.com/go-openapi/swag/stringutils v0.27.1 // indirect github.com/go-openapi/swag/typeutils v0.27.1 // indirect github.com/go-openapi/swag/yamlutils v0.27.1 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -105,7 +109,6 @@ require ( github.com/prometheus/procfs v0.21.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.44.0 // indirect diff --git a/auth/go.sum b/auth/go.sum index 76c31d5ba..fcfb61dbf 100644 --- a/auth/go.sum +++ b/auth/go.sum @@ -22,6 +22,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/ github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aws/aws-sdk-go-v2 v1.46.0 h1:1kt7m/EKcEHt5mlyyxx9cSlMddRPIKbjb6DIQsu4HPk= github.com/aws/aws-sdk-go-v2 v1.46.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2/config v1.33.3 h1:h090b3O5S17bF87/0ysHZuIT/7DCb4EBRFQX2PMVPCw= @@ -127,6 +129,8 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -195,9 +199,14 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4= github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -263,10 +272,13 @@ google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM= diff --git a/auth/options.go b/auth/options.go index 4efdda3ec..70fc3a2a0 100644 --- a/auth/options.go +++ b/auth/options.go @@ -26,6 +26,7 @@ import ( cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" "github.com/fluxcd/pkg/auth/kubernetes" "github.com/fluxcd/pkg/cache" + "github.com/fluxcd/pkg/spiffe/broker" ) // Option is a functional option for the auth package. @@ -34,8 +35,9 @@ type Option func(*Options) // Options contains options for configuring the behavior of the provider methods. // Not all providers/methods support all options. type Options struct { - Client client.Client - Cache *cache.TokenCache + Client client.Client + Cache *cache.TokenCache + SPIFFEBroker broker.Interface // Object entitled to the identity. ObjectReference ObjectReference @@ -193,6 +195,14 @@ func WithCache(cache cache.TokenCache) Option { } } +// WithSPIFFEBroker sets the client used to fetch JWT-SVIDs for the `spiffe` +// credential provider. +func WithSPIFFEBroker(client broker.Interface) Option { + return func(o *Options) { + o.SPIFFEBroker = client + } +} + // WithObject sets the reference to the Flux object entitled to the identity. // The group and kind are provided explicitly because the object may not carry // its TypeMeta, e.g. when it was retrieved from the API server with its @@ -412,18 +422,18 @@ func WithDockerHubOIDCConnectionID(connectionID string) Option { } } -// ObjectLevelWorkloadIdentity returns true if a ServiceAccount token should be -// retrieved for object-level workload identity. The ServiceAccount is provided -// by the caller, either as an object or as a name/namespace reference. -func (o *Options) ObjectLevelWorkloadIdentity() bool { - return o.ServiceAccountName != "" +// Apply applies the given slice of Option(s) to the Options struct. +func (o *Options) Apply(opts ...Option) { + for _, opt := range opts { + opt(o) + } } // ServiceAccountRef returns the ServiceAccount object for the token. If the // object was not set explicitly, it is built from the configured name and // namespace. func (o *Options) ServiceAccountRef() *kubernetes.ServiceAccountReference { - if !o.ObjectLevelWorkloadIdentity() { + if !o.objectLevelWorkloadIdentity() { return nil } return &kubernetes.ServiceAccountReference{ @@ -432,13 +442,6 @@ func (o *Options) ServiceAccountRef() *kubernetes.ServiceAccountReference { } } -// Apply applies the given slice of Option(s) to the Options struct. -func (o *Options) Apply(opts ...Option) { - for _, opt := range opts { - opt(o) - } -} - // GetHTTPClient returns a *http.Client with appropriate timeouts and proxy settings. // The client includes a 10-second timeout to prevent indefinite hangs during token acquisition. func (o *Options) GetHTTPClient() *http.Client { @@ -454,6 +457,31 @@ func (o *Options) GetHTTPClient() *http.Client { } } +// objectLevelWorkloadIdentity returns true if a ServiceAccount token should be +// retrieved for object-level workload identity. The ServiceAccount is provided +// by the caller, either as an object or as a name/namespace reference. +func (o *Options) objectLevelWorkloadIdentity() bool { + return o.ServiceAccountName != "" +} + +// credentialProvider returns the configured credential provider, defaulting to +// Kubernetes for backward compatibility with RFC-0010. +func (o *Options) credentialProvider() string { + if o.CredentialProvider == "" { + return cryptov1.CredentialProviderKubernetes + } + return o.CredentialProvider +} + +// credentialType returns the configured credential type, defaulting to JWT for +// backward compatibility with RFC-0010. +func (o *Options) credentialType() string { + if o.CredentialType == "" { + return cryptov1.CredentialTypeJWT + } + return o.CredentialType +} + func newObjectReference(group, kind string, obj client.Object) ObjectReference { return ObjectReference{ APIGroup: group, diff --git a/auth/options_test.go b/auth/options_test.go index 5185b74f6..a81d745c5 100644 --- a/auth/options_test.go +++ b/auth/options_test.go @@ -75,7 +75,7 @@ func TestOptions_GetHTTPClient(t *testing.T) { } } -func TestOptions_ObjectLevelWorkloadIdentity(t *testing.T) { +func TestOptions_ServiceAccountRef(t *testing.T) { tests := []struct { name string opts []auth.Option @@ -100,9 +100,9 @@ func TestOptions_ObjectLevelWorkloadIdentity(t *testing.T) { var o auth.Options o.Apply(tt.opts...) - result := o.ObjectLevelWorkloadIdentity() + result := o.ServiceAccountRef() != nil if result != tt.expected { - t.Errorf("ObjectLevelWorkloadIdentity() = %v, want %v", result, tt.expected) + t.Errorf("ServiceAccountRef() != nil = %v, want %v", result, tt.expected) } }) } diff --git a/auth/provider.go b/auth/provider.go index 8b9d75815..f66a0ed9e 100644 --- a/auth/provider.go +++ b/auth/provider.go @@ -26,11 +26,11 @@ type Provider interface { // GetName returns the name of the cloud provider. GetName() string - // NewControllerToken returns a token that can be used to authenticate + // NewControllerToken returns a credential that can be used to authenticate // with the cloud provider retrieved from the default source, i.e. from // the environment of the controller pod, e.g. files mounted in the pod, // environment variables, local metadata services, etc. - NewControllerToken(ctx context.Context, opts ...Option) (Token, error) + NewControllerToken(ctx context.Context, opts ...Option) (Credential, error) // GetAudiences returns the audiences the OIDC tokens issued by the // credential provider should have. These are usually strings that represent @@ -43,9 +43,9 @@ type Provider interface { // impersonate, as configured in the options. GetIdentity(opts ...Option) (string, error) - // NewTokenForServiceAccount takes the OIDC token issued for the - // ServiceAccount and returns a token that can be used to authenticate - // with the cloud provider. The implementation should exchange this token - // for a cloud provider access token through the provider's STS service. - NewTokenForServiceAccount(ctx context.Context, oidcToken string, opts ...Option) (Token, error) + // NewAccessCredentialForIdentity takes the identity credential and + // returns a credential that can be used to authenticate with the cloud + // provider. The implementation should exchange this credential for a + // cloud provider access credential through the provider's STS service. + NewAccessCredentialForIdentity(ctx context.Context, identity Credential, opts ...Option) (Credential, error) } diff --git a/auth/provider_test.go b/auth/provider_test.go index 9d5491528..9dc64a86f 100644 --- a/auth/provider_test.go +++ b/auth/provider_test.go @@ -19,6 +19,7 @@ package auth_test import ( "context" "errors" + "fmt" "net/http" "net/url" "testing" @@ -35,32 +36,35 @@ import ( type mockProvider struct { t *testing.T - returnName string - returnIdentity string - returnIdentityErr string - returnRegistryErr string - returnRegistryInput string - returnGitErr string - returnGitInput string - returnGitOptions []auth.Option - returnGitCredentials *auth.GitCredentials - returnRESTConfig *auth.RESTConfig - returnRESTConfigOptsErr string - returnControllerToken auth.Token - returnAccessToken auth.Token - returnRegistryOptions []auth.Option - returnRegistryToken *auth.ArtifactRegistryCredentials - paramAudiences []string - paramServiceAccount *kubernetes.ServiceAccountReference - paramOIDCTokenClient *http.Client - paramOIDCTokenExpiry time.Duration - paramArtifactRepository string - paramGitURL *url.URL - paramCluster string - paramClusterAddress string - paramAccessToken auth.Token - paramAccessTokens []auth.Token - paramAllowShellOut bool + returnName string + returnIdentity string + returnIdentityErr string + returnRegistryErr string + returnRegistryInput string + returnGitErr string + returnGitInput string + returnGitOptions []auth.Option + returnGitCredentials *auth.GitCredentials + returnRESTConfig *auth.RESTConfig + returnRESTConfigOptsErr string + returnControllerCredential auth.Credential + returnAccessCredential auth.Credential + returnRegistryOptions []auth.Option + returnRegistryToken *auth.ArtifactRegistryCredentials + paramAudiences []string + paramServiceAccount *kubernetes.ServiceAccountReference + paramOIDCTokenClient *http.Client + paramOIDCToken string + paramOIDCTokenExpiry time.Duration + paramCredentialProvider string + paramArtifactRepository string + paramGitURL *url.URL + paramCluster string + paramClusterAddress string + paramAccessCredential auth.Credential + paramAccessCredentials []auth.Credential + paramAllowShellOut bool + skipOIDCValidation bool // For multi-token flow (RESTConfig) paramFirstScopes []string @@ -73,9 +77,9 @@ func (m *mockProvider) GetName() string { return m.returnName } -func (m *mockProvider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) { +func (m *mockProvider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Credential, error) { m.checkOptions(opts...) - return m.returnControllerToken, nil + return m.returnControllerCredential, nil } func (m *mockProvider) GetAudiences(ctx context.Context, opts ...auth.Option) ([]string, error) { @@ -102,38 +106,53 @@ func (m *mockProvider) GetIdentity(opts ...auth.Option) (string, error) { return m.returnIdentity, nil } -func (m *mockProvider) NewTokenForServiceAccount(ctx context.Context, oidcToken string, - opts ...auth.Option) (auth.Token, error) { +func (m *mockProvider) NewAccessCredentialForIdentity(ctx context.Context, identity auth.Credential, + opts ...auth.Option) (auth.Credential, error) { m.t.Helper() g := NewWithT(m.t) - // Verify the OIDC token. - token, _, err := jwt.NewParser().ParseUnverified(oidcToken, jwt.MapClaims{}) - g.Expect(err).NotTo(HaveOccurred()) - iss, err := token.Claims.GetIssuer() - g.Expect(err).NotTo(HaveOccurred()) - ctx = oidc.ClientContext(ctx, m.paramOIDCTokenClient) - jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks") - clientIDs := m.paramAudiences - if len(clientIDs) == 0 { - clientIDs = []string{"mock-audience"} + jwtCredential, ok := identity.(*auth.JWTCredential) + if !ok { + return nil, fmt.Errorf("expected *auth.JWTCredential, got %T", identity) } - for _, aud := range clientIDs { - _, err = oidc.NewVerifier(iss, jwks, &oidc.Config{ - ClientID: aud, - SupportedSigningAlgs: []string{token.Method.Alg()}, - }).Verify(ctx, oidcToken) - g.Expect(err).NotTo(HaveOccurred()) + oidcToken := jwtCredential.Token + + if m.paramCredentialProvider != "" { + g.Expect(jwtCredential.Provider).To(Equal(m.paramCredentialProvider)) + } + if m.paramOIDCToken != "" { + g.Expect(oidcToken).To(Equal(m.paramOIDCToken)) } - // Verify the OIDC token expiration, when an expiration is expected. - if m.paramOIDCTokenExpiry > 0 { - exp, err := token.Claims.GetExpirationTime() + // Verify the OIDC credential. + if !m.skipOIDCValidation { + token, _, err := jwt.NewParser().ParseUnverified(oidcToken, jwt.MapClaims{}) g.Expect(err).NotTo(HaveOccurred()) - iat, err := token.Claims.GetIssuedAt() + iss, err := token.Claims.GetIssuer() g.Expect(err).NotTo(HaveOccurred()) - g.Expect(exp.Sub(iat.Time)).To(BeNumerically("~", m.paramOIDCTokenExpiry, time.Minute)) + ctx = oidc.ClientContext(ctx, m.paramOIDCTokenClient) + jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks") + clientIDs := m.paramAudiences + if len(clientIDs) == 0 { + clientIDs = []string{"mock-audience"} + } + for _, aud := range clientIDs { + _, err = oidc.NewVerifier(iss, jwks, &oidc.Config{ + ClientID: aud, + SupportedSigningAlgs: []string{token.Method.Alg()}, + }).Verify(ctx, oidcToken) + g.Expect(err).NotTo(HaveOccurred()) + } + + // Verify the OIDC token expiration, when an expiration is expected. + if m.paramOIDCTokenExpiry > 0 { + exp, err := token.Claims.GetExpirationTime() + g.Expect(err).NotTo(HaveOccurred()) + iat, err := token.Claims.GetIssuedAt() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(exp.Sub(iat.Time)).To(BeNumerically("~", m.paramOIDCTokenExpiry, time.Minute)) + } } var o auth.Options @@ -142,7 +161,7 @@ func (m *mockProvider) NewTokenForServiceAccount(ctx context.Context, oidcToken m.checkOptions(opts...) - return m.returnAccessToken, nil + return m.returnAccessCredential, nil } func (m *mockProvider) ParseArtifactRepository(artifactRepository string) (string, error) { @@ -155,7 +174,7 @@ func (m *mockProvider) ParseArtifactRepository(artifactRepository string) (strin return m.returnRegistryInput, nil } -func (m *mockProvider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string, +func (m *mockProvider) GetAccessCredentialOptionsForArtifactRepository(artifactRepository string, _ ...auth.Option) ([]auth.Option, error) { m.t.Helper() g := NewWithT(m.t) @@ -164,11 +183,11 @@ func (m *mockProvider) GetAccessTokenOptionsForArtifactRepository(artifactReposi } func (m *mockProvider) NewArtifactRegistryCredentials(ctx context.Context, registryInput string, - accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { + accessCredential auth.Credential, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) { m.t.Helper() g := NewWithT(m.t) g.Expect(registryInput).To(Equal(m.paramArtifactRepository)) - g.Expect(accessToken).To(Equal(m.paramAccessToken)) + g.Expect(accessCredential).To(Equal(m.paramAccessCredential)) m.checkOptions(opts...) return m.returnRegistryToken, nil } @@ -183,7 +202,7 @@ func (m *mockProvider) ParseGitRepository(gitURL *url.URL) (string, error) { return m.returnGitInput, nil } -func (m *mockProvider) GetAccessTokenOptionsForGitRepository(gitURL *url.URL) ([]auth.Option, error) { +func (m *mockProvider) GetAccessCredentialOptionsForGitRepository(gitURL *url.URL) ([]auth.Option, error) { m.t.Helper() g := NewWithT(m.t) g.Expect(gitURL).To(Equal(m.paramGitURL)) @@ -191,16 +210,16 @@ func (m *mockProvider) GetAccessTokenOptionsForGitRepository(gitURL *url.URL) ([ } func (m *mockProvider) NewGitCredentials(ctx context.Context, gitInput string, - accessToken auth.Token, opts ...auth.Option) (*auth.GitCredentials, error) { + accessCredential auth.Credential, opts ...auth.Option) (*auth.GitCredentials, error) { m.t.Helper() g := NewWithT(m.t) g.Expect(gitInput).To(Equal(m.returnGitInput)) - g.Expect(accessToken).To(Equal(m.paramAccessToken)) + g.Expect(accessCredential).To(Equal(m.paramAccessCredential)) m.checkOptions(opts...) return m.returnGitCredentials, nil } -func (m *mockProvider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { +func (m *mockProvider) GetAccessCredentialOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) { m.t.Helper() g := NewWithT(m.t) var o auth.Options @@ -212,7 +231,7 @@ func (m *mockProvider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][ return [][]auth.Option{{auth.WithScopes("first-token")}, {auth.WithScopes("second-token")}}, nil } -func (m *mockProvider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token, +func (m *mockProvider) NewRESTConfig(ctx context.Context, accessCredentials []auth.Credential, opts ...auth.Option) (*auth.RESTConfig, error) { m.t.Helper() g := NewWithT(m.t) @@ -220,7 +239,7 @@ func (m *mockProvider) NewRESTConfig(ctx context.Context, accessTokens []auth.To o.Apply(opts...) g.Expect(o.ClusterResource).To(Equal(m.paramCluster)) g.Expect(o.ClusterAddress).To(Equal(m.paramClusterAddress)) - g.Expect(accessTokens).To(Equal(m.paramAccessTokens)) + g.Expect(accessCredentials).To(Equal(m.paramAccessCredentials)) m.checkOptions(opts...) return m.returnRESTConfig, nil } diff --git a/auth/registry.go b/auth/registry.go index b8f3db3ff..a06f38e27 100644 --- a/auth/registry.go +++ b/auth/registry.go @@ -33,9 +33,9 @@ import ( type ArtifactRegistryCredentialsProvider interface { Provider - // GetAccessTokenOptionsForArtifactRepository returns the options that must be - // passed to the provider to retrieve access tokens for an artifact repository. - GetAccessTokenOptionsForArtifactRepository(artifactRepository string, + // GetAccessCredentialOptionsForArtifactRepository returns the options that must be + // passed to the provider to retrieve access credentials for an artifact repository. + GetAccessCredentialOptionsForArtifactRepository(artifactRepository string, opts ...Option) ([]Option, error) // ParseArtifactRepository parses the artifact repository to verify @@ -45,21 +45,21 @@ type ArtifactRegistryCredentialsProvider interface { ParseArtifactRepository(artifactRepository string) (string, error) // NewArtifactRegistryCredentials takes the registry input extracted by - // ParseArtifactRepository() and an access token and returns credentials - // that can be used to authenticate with the registry. + // ParseArtifactRepository() and an access credential and returns + // credentials that can be used to authenticate with the registry. NewArtifactRegistryCredentials(ctx context.Context, registryInput string, - accessToken Token, opts ...Option) (*ArtifactRegistryCredentials, error) + accessCredential Credential, opts ...Option) (*ArtifactRegistryCredentials, error) } -// ArtifactRegistryCredentials is a particular type implementing the Token interface -// for credentials that can be used to authenticate against an artifact registry -// from a cloud provider. +// ArtifactRegistryCredentials is a particular type implementing the Credential +// interface for credentials that can be used to authenticate against an +// artifact registry from a cloud provider. type ArtifactRegistryCredentials struct { authn.Authenticator ExpiresAt time.Time } -// GetDuration implements Token. +// GetDuration implements Credential. func (a *ArtifactRegistryCredentials) GetDuration() time.Duration { return time.Until(a.ExpiresAt) } @@ -74,21 +74,21 @@ func GetArtifactRegistryCredentials(ctx context.Context, provider ArtifactRegist return nil, err } - // First, we need an access token. This cannot be retrieved inside the - // cache lock, otherwise we reach a deadlock. - accessTokenOpts, err := provider.GetAccessTokenOptionsForArtifactRepository(artifactRepository, opts...) + // First, we need an access credential. This cannot be retrieved inside + // the cache lock, otherwise we reach a deadlock. + accessCredentialOpts, err := provider.GetAccessCredentialOptionsForArtifactRepository(artifactRepository, opts...) if err != nil { return nil, err } - accessTokenOpts = append(slices.Clone(opts), accessTokenOpts...) - accessToken, err := GetAccessToken(ctx, provider, accessTokenOpts...) + accessCredentialOpts = append(slices.Clone(opts), accessCredentialOpts...) + accessCredential, err := NewAccessCredential(ctx, provider, accessCredentialOpts...) if err != nil { - return nil, fmt.Errorf("failed to get access token for artifact registry: %w", err) + return nil, fmt.Errorf("failed to get access credential for artifact registry: %w", err) } // Prepare a function to create new credentials. newArtifactRegistryCredentials := func() (*ArtifactRegistryCredentials, error) { - creds, err := provider.NewArtifactRegistryCredentials(ctx, registryInput, accessToken, opts...) + creds, err := provider.NewArtifactRegistryCredentials(ctx, registryInput, accessCredential, opts...) if err != nil { return nil, fmt.Errorf("failed to create artifact registry credentials: %w", err) } @@ -107,7 +107,7 @@ func GetArtifactRegistryCredentials(ctx context.Context, provider ArtifactRegist var serviceAccount *kubernetes.ServiceAccountReference var providerIdentity string var audiences []string - if o.ObjectLevelWorkloadIdentity() { + if o.objectLevelWorkloadIdentity() { var err error serviceAccount, audiences, providerIdentity, err = getServiceAccountAndProviderInfo(ctx, provider, opts...) @@ -115,10 +115,10 @@ func GetArtifactRegistryCredentials(ctx context.Context, provider ArtifactRegist return nil, err } } - accessTokenCacheKey := buildAccessTokenCacheKey(provider, audiences, - providerIdentity, serviceAccount, accessTokenOpts...) + accessCredentialCacheKey := buildAccessCredentialCacheKey(provider, audiences, + providerIdentity, serviceAccount, accessCredentialOpts...) cacheKey := buildCacheKey( - fmt.Sprintf("accessTokenCacheKey=%s", accessTokenCacheKey), + fmt.Sprintf("accessCredentialCacheKey=%s", accessCredentialCacheKey), fmt.Sprintf("artifactRepositoryCacheKey=%s", registryInput)) // Get credentials from cache. diff --git a/auth/registry_test.go b/auth/registry_test.go index 925afa5d6..39ec672e0 100644 --- a/auth/registry_test.go +++ b/auth/registry_test.go @@ -61,14 +61,14 @@ func TestGetArtifactRegistryCredentials(t *testing.T) { expectedErr string }{ { - name: "registry token from controller access token", + name: "registry credential from controller access credential", provider: &mockProvider{ - returnRegistryInput: "some-registry.io/some/artifact", - returnControllerToken: &mockToken{token: "mock-default-token"}, + returnRegistryInput: "some-registry.io/some/artifact", + returnControllerCredential: &mockCredential{token: "mock-default-token"}, returnRegistryToken: &auth.ArtifactRegistryCredentials{ Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}), }, - paramAccessToken: &mockToken{token: "mock-default-token"}, + paramAccessCredential: &mockCredential{token: "mock-default-token"}, paramArtifactRepository: "some-registry.io/some/artifact", }, artifactRepository: "some-registry.io/some/artifact", @@ -85,11 +85,11 @@ func TestGetArtifactRegistryCredentials(t *testing.T) { }, }, { - name: "registry token from access token from service account", + name: "registry credential from access credential from service account", provider: &mockProvider{ - returnName: "mock-provider", - returnRegistryInput: "some-registry.io/some/artifact", - returnAccessToken: &mockToken{token: "mock-access-token"}, + returnName: "mock-provider", + returnRegistryInput: "some-registry.io/some/artifact", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, returnRegistryToken: &auth.ArtifactRegistryCredentials{ Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}), }, @@ -97,7 +97,7 @@ func TestGetArtifactRegistryCredentials(t *testing.T) { paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, paramOIDCTokenClient: oidcClient, paramArtifactRepository: "some-registry.io/some/artifact", - paramAccessToken: &mockToken{token: "mock-access-token"}, + paramAccessCredential: &mockCredential{token: "mock-access-token"}, }, artifactRepository: "some-registry.io/some/artifact", opts: []auth.Option{ @@ -116,11 +116,11 @@ func TestGetArtifactRegistryCredentials(t *testing.T) { }, }, { - name: "registry token from access token from explicit service account", + name: "registry credential from access credential from explicit service account", provider: &mockProvider{ - returnName: "mock-provider", - returnRegistryInput: "some-registry.io/some/artifact", - returnAccessToken: &mockToken{token: "mock-access-token"}, + returnName: "mock-provider", + returnRegistryInput: "some-registry.io/some/artifact", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, returnRegistryToken: &auth.ArtifactRegistryCredentials{ Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}), }, @@ -128,7 +128,7 @@ func TestGetArtifactRegistryCredentials(t *testing.T) { paramServiceAccount: &kubernetes.ServiceAccountReference{Name: defaultServiceAccount.Name, Namespace: defaultServiceAccount.Namespace}, paramOIDCTokenClient: oidcClient, paramArtifactRepository: "some-registry.io/some/artifact", - paramAccessToken: &mockToken{token: "mock-access-token"}, + paramAccessCredential: &mockCredential{token: "mock-access-token"}, }, artifactRepository: "some-registry.io/some/artifact", opts: []auth.Option{ @@ -170,26 +170,26 @@ func TestGetArtifactRegistryCredentials(t *testing.T) { tokenCache, err := cache.NewTokenCache(2) g.Expect(err).NotTo(HaveOccurred()) - const accessTokenKey = "6c016e84cc74ad7bf6bd8770cdb6a2efca3e185fdf90badd985c77984784f5c3" - var token auth.Token = &mockToken{token: "cached-token"} - cachedToken, ok, err := tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + const accessCredentialKey = "6c016e84cc74ad7bf6bd8770cdb6a2efca3e185fdf90badd985c77984784f5c3" + var credential auth.Credential = &mockCredential{token: "cached-token"} + cachedCredential, ok, err := tokenCache.GetOrSet(ctx, accessCredentialKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) - const artifactRegistryCredentialsKey = "99b73881085fc83b83913d3159fadefc1f304dbc0603629a77a0ced83132990d" - token = &auth.ArtifactRegistryCredentials{ + const artifactRegistryCredentialsKey = "62a2203d6403324644e5d1024a6022eae313eedbdba1d21f71a8773488d2b40c" + credential = &auth.ArtifactRegistryCredentials{ Authenticator: authn.FromConfig(authn.AuthConfig{Username: "cached-registry-token"}), ExpiresAt: now.Add(time.Hour), } - cachedToken, ok, err = tokenCache.GetOrSet(ctx, artifactRegistryCredentialsKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + cachedCredential, ok, err = tokenCache.GetOrSet(ctx, artifactRegistryCredentialsKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) o.Cache = tokenCache }, diff --git a/auth/restconfig.go b/auth/restconfig.go index 377bb0b2b..d7ddf0907 100644 --- a/auth/restconfig.go +++ b/auth/restconfig.go @@ -31,23 +31,23 @@ import ( type RESTConfigProvider interface { Provider - // GetAccessTokenOptionsForCluster returns the options that must be - // passed to the provider to retrieve access tokens for a cluster. - // More than one access token may be required depending on the + // GetAccessCredentialOptionsForCluster returns the options that must be + // passed to the provider to retrieve access credentials for a cluster. + // More than one access credential may be required depending on the // provider, with different options (e.g. scope). Hence the return // type is a slice of []Option. - GetAccessTokenOptionsForCluster(opts ...Option) ([][]Option, error) + GetAccessCredentialOptionsForCluster(opts ...Option) ([][]Option, error) // NewRESTConfig returns a new RESTConfig that can be used to authenticate - // with the Kubernetes API server. The access tokens are used for looking - // up connection details like the API server address and CA certificate - // data, and for accessing the cluster API server itself via the IAM - // system of the cloud provider. If it's just a single token or multiple, - // it depends on the provider. - NewRESTConfig(ctx context.Context, accessTokens []Token, opts ...Option) (*RESTConfig, error) + // with the Kubernetes API server. The access credentials are used for + // looking up connection details like the API server address and CA + // certificate data, and for accessing the cluster API server itself via + // the IAM system of the cloud provider. If it's just a single credential + // or multiple, it depends on the provider. + NewRESTConfig(ctx context.Context, accessCredentials []Credential, opts ...Option) (*RESTConfig, error) } -// RESTConfig is a particular type implementing the Token interface +// RESTConfig is a particular type implementing the Credential interface // for Kubernetes REST configurations. type RESTConfig struct { Host string @@ -56,7 +56,7 @@ type RESTConfig struct { ExpiresAt time.Time } -// GetDuration implements Token. +// GetDuration implements Credential. func (r *RESTConfig) GetDuration() time.Duration { return time.Until(r.ExpiresAt) } @@ -69,25 +69,25 @@ func GetRESTConfig(ctx context.Context, provider RESTConfigProvider, opts ...Opt var o Options o.Apply(opts...) - // First, we need the access tokens. They cannot be retrieved inside the - // cache lock, otherwise we reach a deadlock. - accessTokenOpts, err := provider.GetAccessTokenOptionsForCluster(opts...) + // First, we need the access credentials. They cannot be retrieved inside + // the cache lock, otherwise we reach a deadlock. + accessCredentialOpts, err := provider.GetAccessCredentialOptionsForCluster(opts...) if err != nil { return nil, err } - accessTokens := make([]Token, 0, len(accessTokenOpts)) - for i := range accessTokenOpts { - accessTokenOpts[i] = append(slices.Clone(opts), accessTokenOpts[i]...) - token, err := GetAccessToken(ctx, provider, accessTokenOpts[i]...) + accessCredentials := make([]Credential, 0, len(accessCredentialOpts)) + for i := range accessCredentialOpts { + accessCredentialOpts[i] = append(slices.Clone(opts), accessCredentialOpts[i]...) + credential, err := NewAccessCredential(ctx, provider, accessCredentialOpts[i]...) if err != nil { - return nil, fmt.Errorf("failed to get access token for cluster: %w", err) + return nil, fmt.Errorf("failed to get access credential for cluster: %w", err) } - accessTokens = append(accessTokens, token) + accessCredentials = append(accessCredentials, credential) } // Prepare a function to create the restconfig if needed. newRESTConfig := func() (*RESTConfig, error) { - conf, err := provider.NewRESTConfig(ctx, accessTokens, opts...) + conf, err := provider.NewRESTConfig(ctx, accessCredentials, opts...) if err != nil { return nil, err } @@ -103,7 +103,7 @@ func GetRESTConfig(ctx context.Context, provider RESTConfigProvider, opts ...Opt var serviceAccount *kubernetes.ServiceAccountReference var providerIdentity string var audiences []string - if o.ObjectLevelWorkloadIdentity() { + if o.objectLevelWorkloadIdentity() { var err error serviceAccount, audiences, providerIdentity, err = getServiceAccountAndProviderInfo(ctx, provider, opts...) @@ -112,9 +112,9 @@ func GetRESTConfig(ctx context.Context, provider RESTConfigProvider, opts ...Opt } } var cacheKeyParts []string - for i, atOpts := range accessTokenOpts { - key := buildAccessTokenCacheKey(provider, audiences, providerIdentity, serviceAccount, atOpts...) - cacheKeyParts = append(cacheKeyParts, fmt.Sprintf("accessToken%dCacheKey=%s", i, key)) + for i, acOpts := range accessCredentialOpts { + key := buildAccessCredentialCacheKey(provider, audiences, providerIdentity, serviceAccount, acOpts...) + cacheKeyParts = append(cacheKeyParts, fmt.Sprintf("accessCredential%dCacheKey=%s", i, key)) } if c := o.ClusterResource; c != "" { cacheKeyParts = append(cacheKeyParts, fmt.Sprintf("cluster=%s", c)) @@ -127,13 +127,13 @@ func GetRESTConfig(ctx context.Context, provider RESTConfigProvider, opts ...Opt // Build involved object details. cacheOpts := []cache.Options{cache.WithInvolvedObject(o.involvedObject())} - // Get restconfig from cache. - token, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) { + // Get REST config from cache. + credential, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) { return newRESTConfig() }, cacheOpts...) if err != nil { return nil, err } - return token.(*RESTConfig), nil + return credential.(*RESTConfig), nil } diff --git a/auth/restconfig_test.go b/auth/restconfig_test.go index 53088ccf1..8bdd408f5 100644 --- a/auth/restconfig_test.go +++ b/auth/restconfig_test.go @@ -70,9 +70,9 @@ func TestGetRESTConfig(t *testing.T) { expectedErr string }{ { - name: "restconfig from controller access token", + name: "restconfig from controller access credential", provider: &mockProvider{ - returnControllerToken: &mockToken{token: "mock-default-token"}, + returnControllerCredential: &mockCredential{token: "mock-default-token"}, returnRESTConfig: &auth.RESTConfig{ Host: "https://cluster/resource/name", BearerToken: "mock-bearer-token", @@ -83,9 +83,9 @@ func TestGetRESTConfig(t *testing.T) { paramSecondScopes: []string{"second-token"}, expectFirstScopes: true, expectSecondScopes: true, - paramAccessTokens: []auth.Token{ - &mockToken{token: "mock-default-token"}, - &mockToken{token: "mock-default-token"}, + paramAccessCredentials: []auth.Credential{ + &mockCredential{token: "mock-default-token"}, + &mockCredential{token: "mock-default-token"}, }, }, cluster: "cluster/resource/name", @@ -104,10 +104,10 @@ func TestGetRESTConfig(t *testing.T) { }, }, { - name: "restconfig from access token from service account", + name: "restconfig from access credential from service account", provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, returnRESTConfig: &auth.RESTConfig{ Host: "https://cluster/resource/name", BearerToken: "mock-bearer-token", @@ -121,9 +121,9 @@ func TestGetRESTConfig(t *testing.T) { paramSecondScopes: []string{"second-token"}, expectFirstScopes: true, expectSecondScopes: true, - paramAccessTokens: []auth.Token{ - &mockToken{token: "mock-access-token"}, - &mockToken{token: "mock-access-token"}, + paramAccessCredentials: []auth.Credential{ + &mockCredential{token: "mock-access-token"}, + &mockCredential{token: "mock-access-token"}, }, }, cluster: "cluster/resource/name", @@ -175,37 +175,37 @@ func TestGetRESTConfig(t *testing.T) { tokenCache, err := cache.NewTokenCache(3) g.Expect(err).NotTo(HaveOccurred()) - accessTokenKey := "61f0d5d2ddd8c21ebcbab3686c5fe73234d7a825d02062baaa2951565eeb7398" - var token auth.Token = &mockToken{token: "cached-token"} - cachedToken, ok, err := tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + accessCredentialKey := "61f0d5d2ddd8c21ebcbab3686c5fe73234d7a825d02062baaa2951565eeb7398" + var credential auth.Credential = &mockCredential{token: "cached-token"} + cachedCredential, ok, err := tokenCache.GetOrSet(ctx, accessCredentialKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) - accessTokenKey = "583b0d84b5393b72396ad500503a74315e86b7044c3085d470c6597e751438af" - token = &mockToken{token: "cached-token"} - cachedToken, ok, err = tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + accessCredentialKey = "583b0d84b5393b72396ad500503a74315e86b7044c3085d470c6597e751438af" + credential = &mockCredential{token: "cached-token"} + cachedCredential, ok, err = tokenCache.GetOrSet(ctx, accessCredentialKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) - const restConfigKey = "c83bf9b221a0716e21acbda71d59f5502351c811512509f9c7c59090cab39153" - token = &auth.RESTConfig{ + const restConfigKey = "8e65b5b906da5063a5d44023296bf6e83b72ab1f347b96c216a88e7175311f9e" + credential = &auth.RESTConfig{ Host: "https://cluster/resource/name", BearerToken: "mock-bearer-token", CAData: []byte("ca-data"), ExpiresAt: now.Add(time.Hour), } - cachedToken, ok, err = tokenCache.GetOrSet(ctx, restConfigKey, func(ctx context.Context) (cache.Token, error) { - return token, nil + cachedCredential, ok, err = tokenCache.GetOrSet(ctx, restConfigKey, func(ctx context.Context) (cache.Token, error) { + return credential, nil }) g.Expect(err).NotTo(HaveOccurred()) g.Expect(ok).To(BeFalse()) - g.Expect(cachedToken).To(Equal(token)) + g.Expect(cachedCredential).To(Equal(credential)) o.Cache = tokenCache }, @@ -218,7 +218,7 @@ func TestGetRESTConfig(t *testing.T) { }, }, { - name: "error getting access token options for cluster", + name: "error getting access credential options for cluster", provider: &mockProvider{ paramCluster: "cluster/resource/name", returnRESTConfigOptsErr: "mock error", @@ -229,8 +229,8 @@ func TestGetRESTConfig(t *testing.T) { { name: "restconfig from explicit service account", provider: &mockProvider{ - returnName: "mock-provider", - returnAccessToken: &mockToken{token: "mock-access-token"}, + returnName: "mock-provider", + returnAccessCredential: &mockCredential{token: "mock-access-token"}, returnRESTConfig: &auth.RESTConfig{ Host: "https://cluster/resource/name", BearerToken: "mock-bearer-token", @@ -244,9 +244,9 @@ func TestGetRESTConfig(t *testing.T) { paramSecondScopes: []string{"second-token"}, expectFirstScopes: true, expectSecondScopes: true, - paramAccessTokens: []auth.Token{ - &mockToken{token: "mock-access-token"}, - &mockToken{token: "mock-access-token"}, + paramAccessCredentials: []auth.Credential{ + &mockCredential{token: "mock-access-token"}, + &mockCredential{token: "mock-access-token"}, }, }, cluster: "cluster/resource/name", diff --git a/auth/token.go b/auth/token.go deleted file mode 100644 index 9daeaa5ea..000000000 --- a/auth/token.go +++ /dev/null @@ -1,35 +0,0 @@ -/* -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 auth - -import ( - "time" -) - -// Token is an interface that represents an access token that can be used to -// authenticate requests for a cloud provider. The only common method is for -// getting the duration of the token, because different providers have -// different ways of representing the token. For example, Azure and GCP use -// a single string, while AWS uses three strings: access key ID, secret access -// key and token session. Consumers of this interface should know what type to -// cast it to. -type Token interface { - // GetDuration returns the duration for which the token will still be valid - // relative to approximately time.Now(). This is used to determine when the - // token should be renewed. - GetDuration() time.Duration -} diff --git a/auth/utils/git_test.go b/auth/utils/git_test.go index 727c2c9fe..164ac0e3c 100644 --- a/auth/utils/git_test.go +++ b/auth/utils/git_test.go @@ -57,14 +57,14 @@ func TestGetGitCredentials(t *testing.T) { p, err := authutils.GetGitCredentials(context.Background(), "aws", auth.WithGitURL(*u)) g.Expect(err).To(HaveOccurred()) - g.Expect(err.Error()).To(ContainSubstring("failed to create provider access token")) + g.Expect(err.Error()).To(ContainSubstring("failed to create provider access credential")) g.Expect(p).To(BeNil()) }) t.Run("aws/region extracted from URL", func(t *testing.T) { // When no STSRegion is provided but a CodeCommit git URL is given, the // region should be extracted automatically so that object-level workload - // identity (NewTokenForServiceAccount) receives a non-empty STSRegion. + // identity (NewAccessCredentialForIdentity) receives a non-empty STSRegion. g := NewWithT(t) region := "eu-west-1" u, err := url.Parse(fmt.Sprintf("https://git-codecommit.%s.amazonaws.com/v1/repos/repo-name", region)) diff --git a/git/internal/e2e/go.mod b/git/internal/e2e/go.mod index 55b026e7d..10c7f3298 100644 --- a/git/internal/e2e/go.mod +++ b/git/internal/e2e/go.mod @@ -7,6 +7,7 @@ replace ( github.com/fluxcd/pkg/cache => ../../../cache github.com/fluxcd/pkg/git => ../../../git github.com/fluxcd/pkg/gittestserver => ../../../gittestserver + github.com/fluxcd/pkg/spiffe => ../../../spiffe github.com/fluxcd/pkg/ssh => ../../../ssh github.com/fluxcd/pkg/version => ../../../version ) diff --git a/spiffe/broker/client.go b/spiffe/broker/client.go new file mode 100644 index 000000000..834ffb9f2 --- /dev/null +++ b/spiffe/broker/client.go @@ -0,0 +1,124 @@ +/* +Copyright 2026 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 broker + +import ( + "context" + "errors" + "fmt" + "strings" + + brokerapi "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + "github.com/fluxcd/pkg/spiffe" +) + +const ( + // roundRobinServiceConfig makes gRPC load-balance across the endpoints + // returned by the DNS resolver instead of pinning to a single one. + roundRobinServiceConfig = `{"loadBalancingConfig":[{"round_robin":{}}]}` +) + +// Interface provides the operations used to acquire JWT-SVIDs from the SPIFFE +// Broker API. +type Interface interface { + // FetchJWTSVID fetches a JWT-SVID for ref and the given audiences from the + // Broker. The first returned JWT-SVID is used. + FetchJWTSVID(ctx context.Context, ref KubernetesObjectReference, audiences []string) (*jwtsvid.SVID, error) +} + +// Client is a SPIFFE Broker API client that also owns its connection. +type Client interface { + Interface + + // Close closes the connection to the Broker endpoint. + Close() error +} + +// client owns a single gRPC connection to the Broker endpoint, which is +// automatically re-established by gRPC when it breaks. +type client struct { + conn *grpc.ClientConn + api brokerapi.APIClient +} + +var _ Client = (*client)(nil) + +// New returns a client for the SPIFFE Broker API at endpoint. The endpoint must +// be a TCP gRPC target, e.g. +// dns:///spire-server.spire.svc.cluster.local:443. A target without a scheme is +// resolved with the dns resolver so that headless Kubernetes Services can be +// load-balanced across endpoints. +// +// The connection authenticates with the X.509-SVID from source and authorizes +// the Broker endpoint with any of brokerIDs. Each ID may be an exact SPIFFE ID +// or an ID prefix, as parsed by cryptov1.ParseSPIFFEID. +func New(endpoint string, brokerIDs []string, source spiffe.TLSSource) (Client, error) { + if endpoint == "" { + return nil, errors.New("SPIFFE Broker endpoint is required") + } + if len(brokerIDs) == 0 { + return nil, errors.New("at least one SPIFFE Broker ID is required") + } + for i, id := range brokerIDs { + if id == "" { + return nil, fmt.Errorf("SPIFFE Broker ID at index %d is empty", i) + } + } + if source == nil { + return nil, errors.New("SPIFFE TLS source is required") + } + + tlsConfig, err := spiffe.BuildTLSConfig(source, brokerIDs...) + if err != nil { + return nil, err + } + + conn, err := grpc.NewClient(brokerTarget(endpoint), + grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), + grpc.WithDefaultServiceConfig(roundRobinServiceConfig), + ) + if err != nil { + return nil, fmt.Errorf("failed to create SPIFFE Broker client: %w", err) + } + + return &client{ + conn: conn, + api: brokerapi.NewAPIClient(conn), + }, nil +} + +// Close closes the connection to the Broker endpoint. +func (c *client) Close() error { + if c == nil || c.conn == nil { + return nil + } + return c.conn.Close() +} + +// brokerTarget defaults a bare host:port target to the DNS resolver so that +// gRPC can discover and load-balance across endpoints. Targets that already +// carry a scheme are returned unchanged. +func brokerTarget(endpoint string) string { + if strings.Contains(endpoint, "://") { + return endpoint + } + return "dns:///" + endpoint +} diff --git a/spiffe/broker/client_test.go b/spiffe/broker/client_test.go new file mode 100644 index 000000000..5bfbf051c --- /dev/null +++ b/spiffe/broker/client_test.go @@ -0,0 +1,323 @@ +/* +Copyright 2026 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 broker_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "math/big" + "net" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/onsi/gomega" + "github.com/spiffe/go-spiffe/v2/bundle/x509bundle" + brokerapi "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" + + fluxspiffe "github.com/fluxcd/pkg/spiffe" + fluxbroker "github.com/fluxcd/pkg/spiffe/broker" +) + +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate CA key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("failed to create CA certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("failed to parse CA certificate: %v", err) + } + return &testCA{cert: cert, key: key} +} + +func (ca *testCA) newCertificate(t *testing.T, id spiffeid.ID, serial int64) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate SVID key: %v", err) + } + uri, err := url.Parse(id.String()) + if err != nil { + t.Fatalf("failed to parse SPIFFE ID: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + URIs: []*url.URL{uri}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key) + if err != nil { + t.Fatalf("failed to create SVID certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("failed to parse SVID certificate: %v", err) + } + return cert, key +} + +type staticTLSSource struct { + svid *x509svid.SVID + bundles *x509bundle.Set +} + +func newStaticTLSSource(t *testing.T, ca *testCA, id spiffeid.ID, serial int64) *staticTLSSource { + t.Helper() + + cert, key := ca.newCertificate(t, id, serial) + bundle := x509bundle.FromX509Authorities(id.TrustDomain(), []*x509.Certificate{ca.cert}) + return &staticTLSSource{ + svid: &x509svid.SVID{ + ID: id, + Certificates: []*x509.Certificate{cert}, + PrivateKey: key, + }, + bundles: x509bundle.NewSet(bundle), + } +} + +func (s *staticTLSSource) GetX509SVID() (*x509svid.SVID, error) { + return s.svid, nil +} + +func (s *staticTLSSource) GetX509BundleForTrustDomain(trustDomain spiffeid.TrustDomain) (*x509bundle.Bundle, error) { + return s.bundles.GetX509BundleForTrustDomain(trustDomain) +} + +type fakeBrokerAPI struct { + brokerapi.UnimplementedAPIServer + + mtx sync.Mutex + jwt *brokerapi.FetchJWTSVIDResponse + err error + + jwtFetches atomic.Int64 +} + +func (f *fakeBrokerAPI) setJWT(token string) { + f.mtx.Lock() + defer f.mtx.Unlock() + f.jwt = &brokerapi.FetchJWTSVIDResponse{ + Svids: []*brokerapi.JWTSVID{{Svid: token}}, + } + f.err = nil +} + +func (f *fakeBrokerAPI) setError(err error) { + f.mtx.Lock() + defer f.mtx.Unlock() + f.err = err +} + +func (f *fakeBrokerAPI) FetchJWTSVID(context.Context, + *brokerapi.FetchJWTSVIDRequest) (*brokerapi.FetchJWTSVIDResponse, error) { + + f.jwtFetches.Add(1) + f.mtx.Lock() + defer f.mtx.Unlock() + if f.err != nil { + return nil, f.err + } + return f.jwt, nil +} + +func startBrokerAPI(t *testing.T, handler *fakeBrokerAPI, serverTLS *tls.Config) net.Addr { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(serverTLS))) + brokerapi.RegisterAPIServer(server, handler) + go func() { _ = server.Serve(listener) }() + + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + return listener.Addr() +} + +func newClientFixture(t *testing.T) (client fluxbroker.Client, handler *fakeBrokerAPI, + ref fluxbroker.KubernetesObjectReference) { + + t.Helper() + + sourceID := spiffeid.RequireFromString("spiffe://example.org/source-controller") + brokerID := spiffeid.RequireFromString("spiffe://example.org/spire-agent") + ca := newTestCA(t) + + clientSource := newStaticTLSSource(t, ca, sourceID, 1) + serverSource := newStaticTLSSource(t, ca, brokerID, 2) + serverTLS, err := fluxspiffe.BuildTLSConfig(serverSource, sourceID.String()) + if err != nil { + t.Fatalf("failed to build server TLS config: %v", err) + } + + handler = &fakeBrokerAPI{} + listenerAddr := startBrokerAPI(t, handler, serverTLS) + + // The server identifies itself with brokerID. The second value is another + // allowed Broker ID, so this also exercises multiple --spiffe-broker-id + // values. + brokerIDs := []string{"spiffe://example.org/other-spire-agent", brokerID.String()} + client, err = fluxbroker.New(listenerAddr.String(), brokerIDs, clientSource) + if err != nil { + t.Fatalf("failed to create Broker client: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + + ref = fluxbroker.KubernetesObjectReference{ + Group: "source.toolkit.fluxcd.io", + Kind: "OCIRepository", + Namespace: "flux-system", + Name: "my-repo", + UID: "b7b1b3b4-0000-0000-0000-000000000000", + } + return client, handler, ref +} + +func TestNewValidatesBrokerIDs(t *testing.T) { + g := gomega.NewWithT(t) + + _, err := fluxbroker.New("127.0.0.1:1", nil, nil) + g.Expect(err).To(gomega.MatchError("at least one SPIFFE Broker ID is required")) + + _, err = fluxbroker.New("127.0.0.1:1", []string{"spiffe://example.org/agent", ""}, nil) + g.Expect(err).To(gomega.MatchError("SPIFFE Broker ID at index 1 is empty")) +} + +func TestFetchJWTSVID(t *testing.T) { + g := gomega.NewWithT(t) + client, handler, ref := newClientFixture(t) + + handler.setJWT(newTestJWTSVID(t, spiffeid.RequireFromString("spiffe://example.org/ns/foo"), + []string{"my-audience"}, time.Hour)) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + svid, err := client.FetchJWTSVID(ctx, ref, []string{"my-audience"}) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(svid.Expiry).To(gomega.BeTemporally(">", time.Now())) + g.Expect(handler.jwtFetches.Load()).To(gomega.Equal(int64(1))) +} + +func TestFetchJWTSVIDRetries(t *testing.T) { + g := gomega.NewWithT(t) + client, handler, ref := newClientFixture(t) + + handler.setError(status.Error(codes.Unavailable, "down")) + go func() { + time.Sleep(50 * time.Millisecond) + handler.setJWT(newTestJWTSVID(t, spiffeid.RequireFromString("spiffe://example.org/ns/foo"), + []string{"my-audience"}, time.Hour)) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := client.FetchJWTSVID(ctx, ref, []string{"my-audience"}) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(handler.jwtFetches.Load()).To(gomega.BeNumerically(">", 1)) +} + +func TestFetchJWTSVIDPermanentError(t *testing.T) { + g := gomega.NewWithT(t) + client, handler, ref := newClientFixture(t) + + handler.setError(status.Error(codes.PermissionDenied, "denied")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := client.FetchJWTSVID(ctx, ref, []string{"my-audience"}) + g.Expect(status.Code(err)).To(gomega.Equal(codes.PermissionDenied)) + g.Expect(handler.jwtFetches.Load()).To(gomega.Equal(int64(1))) +} + +func newTestJWTSVID(t *testing.T, id spiffeid.ID, audiences []string, ttl time.Duration) string { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("failed to generate JWT key: %v", err) + } + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, nil) + if err != nil { + t.Fatalf("failed to create JWT signer: %v", err) + } + claims, err := json.Marshal(map[string]any{ + "sub": id.String(), + "aud": audiences, + "exp": time.Now().Add(ttl).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + }) + if err != nil { + t.Fatalf("failed to marshal JWT claims: %v", err) + } + jws, err := signer.Sign(claims) + if err != nil { + t.Fatalf("failed to sign JWT: %v", err) + } + token, err := jws.CompactSerialize() + if err != nil { + t.Fatalf("failed to serialize JWT: %v", err) + } + return token +} diff --git a/spiffe/broker/jwt.go b/spiffe/broker/jwt.go new file mode 100644 index 000000000..4b980a61f --- /dev/null +++ b/spiffe/broker/jwt.go @@ -0,0 +1,137 @@ +/* +Copyright 2026 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 broker + +import ( + "context" + "errors" + "fmt" + "time" + + brokerapi "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const ( + // defaultBrokerFetchTimeout bounds a single Broker API fetch, including + // retries, unless the caller's context has an earlier deadline. + defaultBrokerFetchTimeout = 30 * time.Second +) + +// FetchJWTSVID fetches a JWT-SVID for ref and the given audiences from the +// Broker. The first returned JWT-SVID is used. It retries transient failures +// with backoff until the context is done or the fetch timeout elapses. +// Permanent errors, such as PermissionDenied, are returned immediately. +func (c *client) FetchJWTSVID(ctx context.Context, ref KubernetesObjectReference, + audiences []string) (*jwtsvid.SVID, error) { + + if len(audiences) == 0 { + return nil, errors.New("at least one audience is required") + } + if ctx == nil { + return nil, errors.New("context is required") + } + workload, err := ref.workloadReference() + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, defaultBrokerFetchTimeout) + defer cancel() + + for attempt := 1; ; attempt++ { + svid, err := c.fetchJWTSVID(ctx, workload, audiences) + if err == nil { + return svid, nil + } + if !isRetryableError(err) { + return nil, err + } + + select { + case <-time.After(fetchBackoff(attempt)): + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +func (c *client) fetchJWTSVID(ctx context.Context, workload *brokerapi.WorkloadReference, + audiences []string) (*jwtsvid.SVID, error) { + + // The Broker API requires this header on every request as an SSRF mitigation. + ctx = metadata.AppendToOutgoingContext(ctx, "broker.spiffe.io", "true") + resp, err := c.api.FetchJWTSVID(ctx, &brokerapi.FetchJWTSVIDRequest{ + Reference: workload, + Audience: audiences, + }) + if err != nil { + return nil, err + } + if len(resp.GetSvids()) == 0 { + return nil, permanentError{errors.New("SPIFFE Broker returned no JWT-SVIDs")} + } + + svid, err := jwtsvid.ParseInsecure(resp.GetSvids()[0].GetSvid(), audiences) + if err != nil { + return nil, permanentError{fmt.Errorf("failed to parse JWT-SVID: %w", err)} + } + return svid, nil +} + +// fetchBackoff returns the delay before the next fetch attempt. +func fetchBackoff(attempt int) time.Duration { + d := time.Second * time.Duration(attempt) + if d > 30*time.Second { + d = 30 * time.Second + } + return d +} + +// permanentError marks an error that must not be retried. +type permanentError struct { + err error +} + +func (e permanentError) Error() string { + return e.err.Error() +} + +func (e permanentError) Unwrap() error { + return e.err +} + +// isRetryableError reports whether a Broker API error may succeed on retry. +func isRetryableError(err error) bool { + if _, ok := errors.AsType[permanentError](err); ok { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + + switch status.Code(err) { + case codes.InvalidArgument, codes.Unauthenticated, codes.PermissionDenied, + codes.NotFound, codes.FailedPrecondition, codes.Unimplemented: + return false + default: + return true + } +} diff --git a/spiffe/broker/reference.go b/spiffe/broker/reference.go new file mode 100644 index 000000000..00ea6f8b2 --- /dev/null +++ b/spiffe/broker/reference.go @@ -0,0 +1,88 @@ +/* +Copyright 2026 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 broker + +import ( + "errors" + "fmt" + "strings" + + "github.com/gobuffalo/flect" + brokerapi "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" + "google.golang.org/protobuf/types/known/anypb" +) + +// KubernetesObjectReference identifies a Kubernetes object for the SPIFFE +// Broker API. Group is the API group of the object, with an empty value +// normalized to "core" for core resources. +// +// Kind is the singular CRD kind, e.g. "OCIRepository". The Broker API resource +// plural is derived from Kind using the same library and rules as controller-gen. +// At least one of Name or UID must be set. +type KubernetesObjectReference struct { + Group string + Kind string + Namespace string + Name string + UID string +} + +// resourcePlural returns the plural resource name for the reference. +func (r KubernetesObjectReference) resourcePlural() (string, error) { + if r.Kind == "" { + return "", errors.New("Kubernetes object kind is required") + } + // This is the same transformation controller-gen applies to the default + // CRD plural (sigs.k8s.io/controller-tools/pkg/crd/spec.go). + return strings.ToLower(flect.Pluralize(r.Kind)), nil +} + +// workloadReference returns the Broker API workload reference for the +// Kubernetes object. +func (r KubernetesObjectReference) workloadReference() (*brokerapi.WorkloadReference, error) { + group := r.Group + if group == "" { + group = "core" + } + plural, err := r.resourcePlural() + if err != nil { + return nil, err + } + if r.Name == "" && r.UID == "" { + return nil, errors.New("Kubernetes object name or UID is required") + } + + obj := &brokerapi.KubernetesObjectReference{ + Type: &brokerapi.KubernetesObjectType{ + Group: group, + Plural: plural, + }, + Uid: r.UID, + } + if r.Name != "" { + obj.Key = &brokerapi.KubernetesObjectKey{ + Namespace: r.Namespace, + Name: r.Name, + } + } + + ref, err := anypb.New(obj) + if err != nil { + return nil, fmt.Errorf("failed to encode Kubernetes object reference: %w", err) + } + return &brokerapi.WorkloadReference{Reference: ref}, nil +} diff --git a/spiffe/broker/reference_test.go b/spiffe/broker/reference_test.go new file mode 100644 index 000000000..d22530dec --- /dev/null +++ b/spiffe/broker/reference_test.go @@ -0,0 +1,129 @@ +/* +Copyright 2026 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 broker + +import ( + "testing" + + brokerapi "github.com/spiffe/go-spiffe/v2/exp/proto/spiffe/broker" +) + +func TestBrokerTarget(t *testing.T) { + tests := []struct { + endpoint string + want string + }{ + {endpoint: "spire-server.spire.svc:443", want: "dns:///spire-server.spire.svc:443"}, + {endpoint: "dns:///spire-server.spire.svc:443", want: "dns:///spire-server.spire.svc:443"}, + {endpoint: "passthrough:///127.0.0.1:1234", want: "passthrough:///127.0.0.1:1234"}, + {endpoint: "unix:///tmp/broker.sock", want: "unix:///tmp/broker.sock"}, + } + for _, tt := range tests { + t.Run(tt.endpoint, func(t *testing.T) { + if got := brokerTarget(tt.endpoint); got != tt.want { + t.Fatalf("brokerTarget(%q) = %q, want %q", tt.endpoint, got, tt.want) + } + }) + } +} + +func TestKubernetesObjectReference(t *testing.T) { + ref, err := (KubernetesObjectReference{ + Kind: "OCIRepository", + Namespace: "flux-system", + Name: "my-repo", + UID: "b7b1b3b4-0000-0000-0000-000000000000", + }).workloadReference() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var obj brokerapi.KubernetesObjectReference + if err := ref.GetReference().UnmarshalTo(&obj); err != nil { + t.Fatalf("failed to unmarshal reference: %v", err) + } + if obj.GetType().GetGroup() != "core" { + t.Errorf("group = %q, want core", obj.GetType().GetGroup()) + } + if obj.GetType().GetPlural() != "ocirepositories" { + t.Errorf("plural = %q, want ocirepositories", obj.GetType().GetPlural()) + } + if obj.GetKey().GetNamespace() != "flux-system" || obj.GetKey().GetName() != "my-repo" { + t.Errorf("unexpected key: %+v", obj.GetKey()) + } + if obj.GetUid() != "b7b1b3b4-0000-0000-0000-000000000000" { + t.Errorf("unexpected UID: %q", obj.GetUid()) + } +} + +func TestKubernetesObjectReferenceKind(t *testing.T) { + tests := []struct { + kind string + want string + }{ + {kind: "OCIRepository", want: "ocirepositories"}, + {kind: "GitRepository", want: "gitrepositories"}, + {kind: "HelmRepository", want: "helmrepositories"}, + {kind: "HelmChart", want: "helmcharts"}, + {kind: "Bucket", want: "buckets"}, + {kind: "Kustomization", want: "kustomizations"}, + {kind: "HelmRelease", want: "helmreleases"}, + {kind: "ImagePolicy", want: "imagepolicies"}, + {kind: "ImageRepository", want: "imagerepositories"}, + {kind: "ImageUpdateAutomation", want: "imageupdateautomations"}, + {kind: "ExternalArtifact", want: "externalartifacts"}, + {kind: "Alert", want: "alerts"}, + {kind: "Provider", want: "providers"}, + {kind: "Receiver", want: "receivers"}, + } + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + ref, err := (KubernetesObjectReference{ + Kind: tt.kind, + Name: "my-object", + }).workloadReference() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var obj brokerapi.KubernetesObjectReference + if err := ref.GetReference().UnmarshalTo(&obj); err != nil { + t.Fatalf("failed to unmarshal reference: %v", err) + } + if got := obj.GetType().GetPlural(); got != tt.want { + t.Fatalf("plural for kind %q = %q, want %q", tt.kind, got, tt.want) + } + }) + } +} + +func TestKubernetesObjectReferenceValidation(t *testing.T) { + tests := []struct { + name string + ref KubernetesObjectReference + }{ + {name: "missing kind", ref: KubernetesObjectReference{Name: "foo"}}, + {name: "missing name and UID", ref: KubernetesObjectReference{Kind: "OCIRepository"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := tt.ref.workloadReference(); err == nil { + t.Fatal("expected an error") + } + }) + } +} diff --git a/spiffe/controller_flags.go b/spiffe/controller_flags.go index c2559f93e..7770f96cf 100644 --- a/spiffe/controller_flags.go +++ b/spiffe/controller_flags.go @@ -16,33 +16,50 @@ limitations under the License. package spiffe -// Controller flags for authorizing peers in inter-controller communication. -// These flags are repeatable. Each value is parsed by +// Controller flags. All SPIFFE ID flags are repeatable. Each value is parsed by // cryptov1.ParseSPIFFEID and may be an exact SPIFFE ID, a SPIFFE ID prefix, a // SPIFFE trust domain or the `spiffe://self` symbol. const ( + // ControllerFlagSPIFFEBrokerEndpoint defines the flag for the gRPC URL of + // the SPIFFE Broker endpoint, e.g. + // dns:///spire-server.spire.svc.cluster.local:443. The Broker API may be + // served over TCP, unlike the Workload API. + ControllerFlagSPIFFEBrokerEndpoint = "spiffe-broker-endpoint" + + // ControllerFlagSPIFFEBrokerID defines the flag for the SPIFFE IDs of the + // SPIFFE Broker endpoint. This flag is repeatable. Each value may be an + // exact SPIFFE ID or a SPIFFE ID prefix, as parsed by + // cryptov1.ParseSPIFFEID. + ControllerFlagSPIFFEBrokerID = "spiffe-broker-id" + // ControllerFlagAuthorizedArtifactClientSPIFFEID defines the flag for the - // SPIFFE IDs authorized to fetch artifacts from an artifact server. + // SPIFFE IDs authorized to fetch artifacts from an artifact server. This + // flag is repeatable. ControllerFlagAuthorizedArtifactClientSPIFFEID = "authorized-artifact-client-spiffe-id" // ControllerFlagAuthorizedArtifactServerSPIFFEID defines the flag for the - // SPIFFE IDs of artifact servers authorized by an artifact client. + // SPIFFE IDs of artifact servers authorized by an artifact client. This + // flag is repeatable. ControllerFlagAuthorizedArtifactServerSPIFFEID = "authorized-artifact-server-spiffe-id" // ControllerFlagAuthorizedNotifierSPIFFEID defines the flag for the - // SPIFFE IDs authorized to send events to an event server. + // SPIFFE IDs authorized to send events to an event server. This flag is + // repeatable. ControllerFlagAuthorizedNotifierSPIFFEID = "authorized-notifier-spiffe-id" // ControllerFlagAuthorizedEventServerSPIFFEID defines the flag for the - // SPIFFE IDs of event servers authorized by an event client. + // SPIFFE IDs of event servers authorized by an event client. This flag is + // repeatable. ControllerFlagAuthorizedEventServerSPIFFEID = "authorized-event-server-spiffe-id" // ControllerFlagAuthorizedReceiverSPIFFEID defines the flag for the - // SPIFFE IDs authorized to send requests to a receiver server. + // SPIFFE IDs authorized to send requests to a receiver server. This flag + // is repeatable. ControllerFlagAuthorizedReceiverSPIFFEID = "authorized-receiver-spiffe-id" // ControllerFlagAuthorizedKubeAPIServerSPIFFEID defines the flag for the - // SPIFFE IDs of Kubernetes API servers authorized by a client. + // SPIFFE IDs of Kubernetes API servers authorized by a client. This flag + // is repeatable. // // Note: Kubernetes does not natively support SPIFFE for API server TLS. // We define this flag here as per the RFC for it to be implemented after diff --git a/spiffe/doc.go b/spiffe/doc.go index 992b13d4b..1533f8bbd 100644 --- a/spiffe/doc.go +++ b/spiffe/doc.go @@ -14,6 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package spiffe provides helpers for securing communication with SPIFFE -// X.509-SVIDs acquired from the SPIFFE Workload API. +// Package spiffe provides helpers for using the SPIFFE Workload +// and Broker APIs. package spiffe diff --git a/spiffe/go.mod b/spiffe/go.mod index 17b8b6005..622f858b5 100644 --- a/spiffe/go.mod +++ b/spiffe/go.mod @@ -4,16 +4,17 @@ go 1.27.0 require ( github.com/fluxcd/pkg/apis/crypto v0.4.0 + github.com/go-jose/go-jose/v4 v4.1.5 + github.com/gobuffalo/flect v1.0.3 github.com/onsi/gomega v1.43.0 github.com/spiffe/go-spiffe/v2 v2.9.0 google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 ) -replace github.com/fluxcd/pkg/apis/crypto => ../apis/crypto - require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/go-jose/go-jose/v4 v4.1.5 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.22.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -22,5 +23,6 @@ require ( golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/protobuf v1.36.12 // indirect ) + +replace github.com/fluxcd/pkg/apis/crypto => ../apis/crypto diff --git a/spiffe/go.sum b/spiffe/go.sum index ea7805233..0a7e504f6 100644 --- a/spiffe/go.sum +++ b/spiffe/go.sum @@ -2,12 +2,16 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA= github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -20,8 +24,15 @@ github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4= github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -52,3 +63,6 @@ google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/spiffe/tls.go b/spiffe/tls.go index 5c9be4dd6..82409610f 100644 --- a/spiffe/tls.go +++ b/spiffe/tls.go @@ -29,9 +29,9 @@ import ( cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" ) -// X509Source provides the X.509-SVID and trust bundles used to build a TLS +// TLSSource provides the X.509-SVID and trust bundles used to build a TLS // configuration. It is implemented by *workloadapi.X509Source. -type X509Source interface { +type TLSSource interface { x509svid.Source x509bundle.Source } @@ -48,9 +48,9 @@ type X509Source interface { // authorization in each direction should build a configuration for each // direction. The TLS hostname is not used for peer verification; the SPIFFE // identity is verified instead. -func BuildTLSConfig(source X509Source, peers ...string) (*tls.Config, error) { +func BuildTLSConfig(source TLSSource, peers ...string) (*tls.Config, error) { if source == nil { - return nil, errors.New("SPIFFE X.509 source is required") + return nil, errors.New("SPIFFE TLS source is required") } authorizer, err := buildAuthorizer(source, peers) @@ -69,7 +69,7 @@ func BuildTLSConfig(source X509Source, peers ...string) (*tls.Config, error) { }, nil } -func buildAuthorizer(source X509Source, peers []string) (tlsconfig.Authorizer, error) { +func buildAuthorizer(source TLSSource, peers []string) (tlsconfig.Authorizer, error) { if len(peers) == 0 { return nil, errors.New("at least one SPIFFE peer is required") } @@ -89,7 +89,7 @@ func buildAuthorizer(source X509Source, peers []string) (tlsconfig.Authorizer, e return tlsconfig.AdaptMatcher(matchAnyOf(matchers...)), nil } -func peerMatcher(source X509Source, peer cryptov1.SPIFFEPeerAuthorization) (spiffeid.Matcher, error) { +func peerMatcher(source TLSSource, peer cryptov1.SPIFFEPeerAuthorization) (spiffeid.Matcher, error) { if peer.UseSelfTrustDomain { svid, err := source.GetX509SVID() if err != nil { diff --git a/spiffe/tls_internal_test.go b/spiffe/tls_internal_test.go index 1a3506627..42ed7e7c1 100644 --- a/spiffe/tls_internal_test.go +++ b/spiffe/tls_internal_test.go @@ -27,15 +27,15 @@ import ( cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" ) -type fakeX509Source struct { +type fakeTLSSource struct { svid *x509svid.SVID } -func (f fakeX509Source) GetX509SVID() (*x509svid.SVID, error) { +func (f fakeTLSSource) GetX509SVID() (*x509svid.SVID, error) { return f.svid, nil } -func (f fakeX509Source) GetX509BundleForTrustDomain(spiffeid.TrustDomain) (*x509bundle.Bundle, error) { +func (f fakeTLSSource) GetX509BundleForTrustDomain(spiffeid.TrustDomain) (*x509bundle.Bundle, error) { return nil, nil } @@ -47,7 +47,7 @@ func TestPeerMatcherInvalidAuthorization(t *testing.T) { } func TestPeerMatcherSelfInvalidTrustDomain(t *testing.T) { - source := fakeX509Source{svid: &x509svid.SVID{}} + source := fakeTLSSource{svid: &x509svid.SVID{}} id := spiffeid.RequireFromString("spiffe://example.org/foo") prefix := spiffeid.RequireFromString("spiffe://example.org/ns") diff --git a/spiffe/tls_test.go b/spiffe/tls_test.go index e08ddf38c..faa0083ce 100644 --- a/spiffe/tls_test.go +++ b/spiffe/tls_test.go @@ -110,7 +110,7 @@ func TestBuildTLSConfigValidation(t *testing.T) { source, _ := newTestSource(t, id) _, err := fluxspiffe.BuildTLSConfig(nil, id.String()) - g.Expect(err).To(MatchError("SPIFFE X.509 source is required")) + g.Expect(err).To(MatchError("SPIFFE TLS source is required")) _, err = fluxspiffe.BuildTLSConfig(source) g.Expect(err).To(MatchError("at least one SPIFFE peer is required")) diff --git a/tests/integration/go.mod b/tests/integration/go.mod index 9482b5b89..45788a4f3 100644 --- a/tests/integration/go.mod +++ b/tests/integration/go.mod @@ -10,6 +10,7 @@ replace ( github.com/fluxcd/pkg/git => ../../git github.com/fluxcd/pkg/gittestserver => ../../gittestserver github.com/fluxcd/pkg/runtime => ../../runtime + github.com/fluxcd/pkg/spiffe => ../../spiffe github.com/fluxcd/pkg/ssh => ../../ssh github.com/fluxcd/pkg/version => ../../version ) @@ -89,6 +90,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fluxcd/pkg/spiffe v0.0.0 // indirect github.com/fluxcd/pkg/ssh v0.28.0 // indirect github.com/fluxcd/pkg/version v0.19.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect @@ -96,6 +98,7 @@ require ( github.com/go-errors/errors v1.5.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.5 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect @@ -113,6 +116,7 @@ require ( github.com/go-openapi/swag/stringutils v0.29.1 // indirect github.com/go-openapi/swag/typeutils v0.29.1 // indirect github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect diff --git a/tests/integration/go.sum b/tests/integration/go.sum index b0486283c..ae1a4d2f7 100644 --- a/tests/integration/go.sum +++ b/tests/integration/go.sum @@ -191,6 +191,8 @@ github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7s github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -330,6 +332,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spiffe/go-spiffe/v2 v2.9.0 h1:g5qTg3qIP1HXV+GzuyEfSJFVxUCpEP5Ui1OINVJHPB4= github.com/spiffe/go-spiffe/v2 v2.9.0/go.mod h1:w2CLWKLMTX/PPYUEUPv3ltH0RXsw5S8suwNF46w9/Aw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -337,6 +341,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=