From 316edc33e867e3f084c65c1da7168ed409885d61 Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Sun, 18 Jan 2026 23:22:31 +0000 Subject: [PATCH] Introduce support for SA token credential in OCIRepository Signed-off-by: Matheus Pimenta --- api/v1/ocirepository_types.go | 26 ++++- api/v1/zz_generated.deepcopy.go | 5 + ...rce.toolkit.fluxcd.io_ocirepositories.yaml | 37 +++++- docs/api/v1/source.md | 76 ++++++++++++- docs/spec/v1/ocirepositories.md | 93 ++++++++++++++++ go.mod | 4 +- go.sum | 8 +- .../controller/ocirepository_controller.go | 22 +++- .../ocirepository_controller_test.go | 105 ++++++++++++++++++ 9 files changed, 359 insertions(+), 17 deletions(-) diff --git a/api/v1/ocirepository_types.go b/api/v1/ocirepository_types.go index 8c4d3f0fc..b9e2a306f 100644 --- a/api/v1/ocirepository_types.go +++ b/api/v1/ocirepository_types.go @@ -54,6 +54,9 @@ const ( ) // OCIRepositorySpec defines the desired state of OCIRepository +// +kubebuilder:validation:XValidation:rule="!has(self.audiences) || size(self.audiences) == 0 || (has(self.credential) && self.credential == 'ServiceAccountToken')", message="spec.audiences can be set only when spec.credential is set to 'ServiceAccountToken'" +// +kubebuilder:validation:XValidation:rule="!has(self.credential) || self.credential != 'ServiceAccountToken' || (has(self.audiences) && size(self.audiences) > 0)", message="spec.audiences must be set when spec.credential is set to 'ServiceAccountToken'" +// +kubebuilder:validation:XValidation:rule="!has(self.credential) || self.credential != 'ServiceAccountToken' || !has(self.provider) || self.provider == 'generic'", message="spec.credential 'ServiceAccountToken' can only be used with spec.provider 'generic'" type OCIRepositorySpec struct { // URL is a reference to an OCI artifact repository hosted // on a remote container registry. @@ -71,13 +74,32 @@ type OCIRepositorySpec struct { // +optional LayerSelector *OCILayerSelector `json:"layerSelector,omitempty"` - // The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. - // When not specified, defaults to 'generic'. + // Provider is the provider used for authentication, can be 'aws', 'azure', + // 'gcp' or 'generic'. When not specified, defaults to 'generic'. // +kubebuilder:validation:Enum=generic;aws;azure;gcp // +kubebuilder:default:=generic // +optional Provider string `json:"provider,omitempty"` + // Credential specifies the type of credential that will be sent to the input provider. + // Supported values are: + // + // - ServiceAccountToken: The controller will generate a Kubernetes + // ServiceAccount token and send it as a bearer token in the OCI + // registry calls. If ServiceAccountName is not specified, the + // ServiceAccount of the controller will be used to generate the + // token. Can only be used with the 'generic' provider. + // + // +kubebuilder:validation:Enum=ServiceAccountToken + // +optional + Credential string `json:"credential,omitempty"` + + // Audiences specifies the audience claim to be set in JWT credentials, + // like the ServiceAccountToken credential. Required when using JWT + // credentials. + // +optional + Audiences []string `json:"audiences,omitempty"` + // SecretRef contains the secret name containing the registry login // credentials to resolve image metadata. // The secret must be of type kubernetes.io/dockerconfigjson. diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 14f1ba3c2..db58595d3 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -876,6 +876,11 @@ func (in *OCIRepositorySpec) DeepCopyInto(out *OCIRepositorySpec) { *out = new(OCILayerSelector) **out = **in } + if in.Audiences != nil { + in, out := &in.Audiences, &out.Audiences + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.SecretRef != nil { in, out := &in.SecretRef, &out.SecretRef *out = new(meta.LocalObjectReference) diff --git a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml index 05b7b96ab..07fec8e98 100644 --- a/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml +++ b/config/crd/bases/source.toolkit.fluxcd.io_ocirepositories.yaml @@ -54,6 +54,14 @@ spec: spec: description: OCIRepositorySpec defines the desired state of OCIRepository properties: + audiences: + description: |- + Audiences specifies the audience claim to be set in JWT credentials, + like the ServiceAccountToken credential. Required when using JWT + credentials. + items: + type: string + type: array certSecretRef: description: |- CertSecretRef can be given the name of a Secret containing @@ -75,6 +83,19 @@ spec: required: - name type: object + credential: + description: |- + Credential specifies the type of credential that will be sent to the input provider. + Supported values are: + + - ServiceAccountToken: The controller will generate a Kubernetes + ServiceAccount token and send it as a bearer token in the OCI + registry calls. If ServiceAccountName is not specified, the + ServiceAccount of the controller will be used to generate the + token. Can only be used with the 'generic' provider. + enum: + - ServiceAccountToken + type: string ignore: description: |- Ignore overrides the set of excluded patterns in the .sourceignore format @@ -117,8 +138,8 @@ spec: provider: default: generic description: |- - The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. - When not specified, defaults to 'generic'. + Provider is the provider used for authentication, can be 'aws', 'azure', + 'gcp' or 'generic'. When not specified, defaults to 'generic'. enum: - generic - aws @@ -253,6 +274,18 @@ spec: - interval - url type: object + x-kubernetes-validations: + - message: spec.audiences can be set only when spec.credential is set + to 'ServiceAccountToken' + rule: '!has(self.audiences) || size(self.audiences) == 0 || (has(self.credential) + && self.credential == ''ServiceAccountToken'')' + - message: spec.audiences must be set when spec.credential is set to 'ServiceAccountToken' + rule: '!has(self.credential) || self.credential != ''ServiceAccountToken'' + || (has(self.audiences) && size(self.audiences) > 0)' + - message: spec.credential 'ServiceAccountToken' can only be used with + spec.provider 'generic' + rule: '!has(self.credential) || self.credential != ''ServiceAccountToken'' + || !has(self.provider) || self.provider == ''generic''' status: default: observedGeneration: -1 diff --git a/docs/api/v1/source.md b/docs/api/v1/source.md index 935d74275..51d2cdeaa 100644 --- a/docs/api/v1/source.md +++ b/docs/api/v1/source.md @@ -1151,8 +1151,42 @@ string (Optional) -

The provider used for authentication, can be ‘aws’, ‘azure’, ‘gcp’ or ‘generic’. -When not specified, defaults to ‘generic’.

+

Provider is the provider used for authentication, can be ‘aws’, ‘azure’, +‘gcp’ or ‘generic’. When not specified, defaults to ‘generic’.

+ + + + +credential
+ +string + + + +(Optional) +

Credential specifies the type of credential that will be sent to the input provider. +Supported values are:

+ + + + + +audiences
+ +[]string + + + +(Optional) +

Audiences specifies the audience claim to be set in JWT credentials, +like the ServiceAccountToken credential. Required when using JWT +credentials.

@@ -3323,8 +3357,42 @@ string (Optional) -

The provider used for authentication, can be ‘aws’, ‘azure’, ‘gcp’ or ‘generic’. -When not specified, defaults to ‘generic’.

+

Provider is the provider used for authentication, can be ‘aws’, ‘azure’, +‘gcp’ or ‘generic’. When not specified, defaults to ‘generic’.

+ + + + +credential
+ +string + + + +(Optional) +

Credential specifies the type of credential that will be sent to the input provider. +Supported values are:

+ + + + + +audiences
+ +[]string + + + +(Optional) +

Audiences specifies the audience claim to be set in JWT credentials, +like the ServiceAccountToken credential. Required when using JWT +credentials.

diff --git a/docs/spec/v1/ocirepositories.md b/docs/spec/v1/ocirepositories.md index d2bfa399e..63920fb26 100644 --- a/docs/spec/v1/ocirepositories.md +++ b/docs/spec/v1/ocirepositories.md @@ -255,6 +255,99 @@ which can be bound as part of the Container Registry Service Agent role. Take a look at [this guide](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) for more information about setting up GKE Workload Identity. +### Credential + +`.spec.credential` is an optional field that specifies the type of credential +to use for authentication. + +Supported values are: + +- `ServiceAccountToken` + +#### ServiceAccountToken + +The `ServiceAccountToken` credential type instructs the controller to generate +a Kubernetes ServiceAccount token and use it as a bearer token in OCI registry +calls. This is useful for authenticating with OCI registries that support +Kubernetes ServiceAccount token authentication, such as registries configured +with OIDC federation to trust tokens from a Kubernetes cluster. + +When using `ServiceAccountToken`, you must also specify the +[`.spec.audiences`](#audiences) field to set the audience claim in the token. + +If `.spec.serviceAccountName` is specified, the controller will generate a +token for that ServiceAccount. Otherwise, the controller's own ServiceAccount +will be used. + +**Note:** The `ServiceAccountToken` credential can only be used with the +`generic` provider (or when no provider is specified, which defaults to +`generic`). + +Example: + +```yaml +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: example + namespace: default +spec: + interval: 5m0s + url: oci://registry.example.com/my-org/my-artifact + credential: ServiceAccountToken + audiences: + - registry.example.com +``` + +To use a specific ServiceAccount for token generation: + +```yaml +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: example + namespace: default +spec: + interval: 5m0s + url: oci://registry.example.com/my-org/my-artifact + credential: ServiceAccountToken + audiences: + - registry.example.com + serviceAccountName: my-service-account +``` + +**Note:** When using `.spec.serviceAccountName` with `ServiceAccountToken`, +the controller feature gate `ObjectLevelWorkloadIdentity` must be enabled. + +### Audiences + +`.spec.audiences` is a field to specify the audience claims to be set in JWT +credentials. This field is required when `.spec.credential` is set to +`ServiceAccountToken`. + +The audiences are typically the identifiers of the services that will validate +the token. For OCI registries, this is usually the registry hostname or a +specific audience value configured in the registry's OIDC settings. + +Example: + +```yaml +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: example + namespace: default +spec: + interval: 5m0s + url: oci://registry.example.com/my-org/my-artifact + credential: ServiceAccountToken + audiences: + - registry.example.com +``` + ### Secret reference `.spec.secretRef.name` is an optional field to specify a name reference to a diff --git a/go.mod b/go.mod index 56d923b22..a70a7e370 100644 --- a/go.mod +++ b/go.mod @@ -23,9 +23,9 @@ require ( github.com/elazarl/goproxy v1.7.2 github.com/fluxcd/cli-utils v0.36.0-flux.15 github.com/fluxcd/pkg/apis/event v0.21.0 - github.com/fluxcd/pkg/apis/meta v1.23.0 + github.com/fluxcd/pkg/apis/meta v1.24.0 github.com/fluxcd/pkg/artifact v0.5.0 - github.com/fluxcd/pkg/auth v0.33.0 + github.com/fluxcd/pkg/auth v0.34.1-0.20260118212638-6e3e8ddfe8fe github.com/fluxcd/pkg/cache v0.12.0 github.com/fluxcd/pkg/git v0.40.0 github.com/fluxcd/pkg/gittestserver v0.23.0 diff --git a/go.sum b/go.sum index f78e66758..141ac9459 100644 --- a/go.sum +++ b/go.sum @@ -370,12 +370,12 @@ github.com/fluxcd/pkg/apis/acl v0.9.0 h1:wBpgsKT+jcyZEcM//OmZr9RiF8klL3ebrDp2u2T github.com/fluxcd/pkg/apis/acl v0.9.0/go.mod h1:TttNS+gocsGLwnvmgVi3/Yscwqrjc17+vhgYfqkfrV4= github.com/fluxcd/pkg/apis/event v0.21.0 h1:VVl0WmgDXJwDS3Pivkk+31h3fWHbq+BpbNLUF5d61ec= github.com/fluxcd/pkg/apis/event v0.21.0/go.mod h1:jacQdE6DdxoBsUOLMzEZNtpd4TqtYaiH1DWoyHMSUSo= -github.com/fluxcd/pkg/apis/meta v1.23.0 h1:fLis5YcHnOsyKYptzBtituBm5EWNx13I0bXQsy0FG4s= -github.com/fluxcd/pkg/apis/meta v1.23.0/go.mod h1:UWsIbBPCxYvoVklr2mV2uLFBf/n17dNAmKFjRfApdDo= +github.com/fluxcd/pkg/apis/meta v1.24.0 h1:+e33T4OL9oqMWZSltsgImvi+/Punx42X9NqFlPesH6o= +github.com/fluxcd/pkg/apis/meta v1.24.0/go.mod h1:UWsIbBPCxYvoVklr2mV2uLFBf/n17dNAmKFjRfApdDo= github.com/fluxcd/pkg/artifact v0.5.0 h1:9voZe+lEBTM2rzKS+SojavNXEL2h77VfefgagfbBPco= github.com/fluxcd/pkg/artifact v0.5.0/go.mod h1:w/tkU39ogFvO5AAJgNgOd2Da0HEmdh+Yxl+G9L3w/rE= -github.com/fluxcd/pkg/auth v0.33.0 h1:3ccwqpBr8uWEQgl15b7S0PwJ9EgtcKObg4J1jnaof2w= -github.com/fluxcd/pkg/auth v0.33.0/go.mod h1:ZAFC8pNZxhe+7RV2cQO1K9X62HM8BbRBnCE118oY/0A= +github.com/fluxcd/pkg/auth v0.34.1-0.20260118212638-6e3e8ddfe8fe h1:NSz+6rUo31uy9owVgv8NCRbDNh48DQFOPEHVqUZTC5I= +github.com/fluxcd/pkg/auth v0.34.1-0.20260118212638-6e3e8ddfe8fe/go.mod h1:BIz/zxLVz5o8EYQv+2c+ifAeaLq9wr4azXPdWYOU2AY= github.com/fluxcd/pkg/cache v0.12.0 h1:mabABT3jIfuo84VbIW+qvfqMZ7PbM5tXQgQvA2uo2rc= github.com/fluxcd/pkg/cache v0.12.0/go.mod h1:HL/9cgBmwCdKIr3JH57rxrGdb7rOgX5Z1eJlHsaV1vE= github.com/fluxcd/pkg/git v0.40.0 h1:B23gcdNqHQcVpp9P2BU4mrfFXGA8XFYi9mpy+5RDAQA= diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index 003d4e24d..e46962a2a 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -52,6 +52,7 @@ import ( "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/artifact/storage" "github.com/fluxcd/pkg/auth" + "github.com/fluxcd/pkg/auth/serviceaccounttoken" "github.com/fluxcd/pkg/cache" "github.com/fluxcd/pkg/oci" "github.com/fluxcd/pkg/runtime/conditions" @@ -367,12 +368,24 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch } } - if _, ok := keychain.(soci.Anonymous); obj.Spec.Provider != "" && obj.Spec.Provider != sourcev1.GenericOCIProvider && ok { + provider := obj.Spec.Provider + if _, ok := keychain.(soci.Anonymous); ok && + (provider != "" && provider != sourcev1.GenericOCIProvider) || + obj.Spec.Credential == serviceaccounttoken.CredentialName { + opts := []auth.Option{ auth.WithClient(r.Client), auth.WithServiceAccountNamespace(obj.GetNamespace()), } + if obj.Spec.Credential == serviceaccounttoken.CredentialName { + provider = serviceaccounttoken.CredentialName + } + + if a := obj.Spec.Audiences; len(a) > 0 { + opts = append(opts, auth.WithAudiences(a...)) + } + if obj.Spec.ServiceAccountName != "" { // Check object-level workload identity feature gate. if !auth.IsObjectLevelWorkloadIdentityEnabled() { @@ -384,6 +397,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch // Set ServiceAccountName only if explicitly specified opts = append(opts, auth.WithServiceAccountName(obj.Spec.ServiceAccountName)) } + if r.TokenCache != nil { involvedObject := cache.InvolvedObject{ Kind: sourcev1.OCIRepositoryKind, @@ -393,14 +407,16 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch } opts = append(opts, auth.WithCache(*r.TokenCache, involvedObject)) } + if proxyURL != nil { opts = append(opts, auth.WithProxyURL(*proxyURL)) } + var authErr error - authenticator, authErr = soci.OIDCAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider, opts...) + authenticator, authErr = soci.OIDCAuth(ctxTimeout, obj.Spec.URL, provider, opts...) if authErr != nil { e := serror.NewGeneric( - fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr), + fmt.Errorf("failed to get credential from %s: %w", provider, authErr), sourcev1.AuthenticationFailedReason, ) conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) diff --git a/internal/controller/ocirepository_controller_test.go b/internal/controller/ocirepository_controller_test.go index 6ea35e962..c8347669c 100644 --- a/internal/controller/ocirepository_controller_test.go +++ b/internal/controller/ocirepository_controller_test.go @@ -427,6 +427,8 @@ func TestOCIRepository_reconcileSource_authStrategy(t *testing.T) { insecure bool provider string providerImg string + credential string + audiences []string want sreconcile.Result wantErr bool assertConditions []metav1.Condition @@ -711,6 +713,19 @@ func TestOCIRepository_reconcileSource_authStrategy(t *testing.T) { *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "%s", "building artifact: new revision '' for ''"), }, }, + { + name: "with ServiceAccountToken credential", + wantErr: true, + credential: "ServiceAccountToken", + audiences: []string{"test-audience"}, + craneOpts: []crane.Option{ + crane.Insecure, + }, + insecure: true, + assertConditions: []metav1.Condition{ + *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "%s", "failed to get credential from ServiceAccountToken"), + }, + }, } for _, tt := range tests { @@ -756,6 +771,13 @@ func TestOCIRepository_reconcileSource_authStrategy(t *testing.T) { obj.Spec.URL = tt.providerImg } + if tt.credential != "" { + obj.Spec.Credential = tt.credential + } + if len(tt.audiences) > 0 { + obj.Spec.Audiences = tt.audiences + } + if tt.secretOpts.username != "" && tt.secretOpts.password != "" { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -3082,6 +3104,89 @@ func TestOCIRepository_objectLevelWorkloadIdentityFeatureGate(t *testing.T) { }).Should(BeTrue()) } +func TestOCIRepositoryReconciler_APIServerValidation_Credential(t *testing.T) { + tests := []struct { + name string + provider string + credential string + audiences []string + err string + }{ + { + name: "ServiceAccountToken requires audiences", + credential: "ServiceAccountToken", + err: "spec.audiences must be set when spec.credential is set to 'ServiceAccountToken'", + }, + { + name: "audiences requires ServiceAccountToken credential", + audiences: []string{"test-audience"}, + err: "spec.audiences can be set only when spec.credential is set to 'ServiceAccountToken'", + }, + { + name: "ServiceAccountToken only works with generic provider", + provider: "aws", + credential: "ServiceAccountToken", + audiences: []string{"test-audience"}, + err: "spec.credential 'ServiceAccountToken' can only be used with spec.provider 'generic'", + }, + { + name: "ServiceAccountToken works with generic provider", + provider: "generic", + credential: "ServiceAccountToken", + audiences: []string{"test-audience"}, + }, + { + name: "ServiceAccountToken works with default provider", + credential: "ServiceAccountToken", + audiences: []string{"test-audience"}, + }, + { + name: "aws provider can be created without credential", + provider: "aws", + }, + { + name: "generic provider can be created without credential", + provider: "generic", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "ocirepository-validation-", + Namespace: "default", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/test/test", + Interval: metav1.Duration{Duration: interval}, + Timeout: &metav1.Duration{Duration: timeout}, + Provider: tt.provider, + Credential: tt.credential, + Audiences: tt.audiences, + }, + } + + err := testEnv.Create(ctx, obj) + if err == nil { + defer func() { + err := testEnv.Delete(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) + }() + } + + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.err)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + }) + } +} + func TestOCIRepository_reconcileStorage(t *testing.T) { tests := []struct { name string