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:
+
+- 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.
+
+ |
+
+
+
+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:
+
+- 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.
+
+ |
+
+
+
+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