Browse Source

feat(conjur): Certificate Auth support (#6393)

* Conjur Certificate Auth support

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* Add max and min properties validations to ConjurAuth struct

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* Update docs/provider/conjur.md

Co-authored-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>
Signed-off-by: Hubert Dąbrowski <131349909+hdabrowski@users.noreply.github.com>
Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* Add missing required and optional statements to secretstore types

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* Add missing required and optional statements to secretstore types

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* Add more validations to ConjurAuth struct

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* fix e2e SPIFFE cert-auth scenarios

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* fix e2e local setup issues

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* pin the version of conjur to download authenticator service from

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

* pin the version of conjur charts

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>

---------

Signed-off-by: Hubert Dąbrowski <hubert.dabrowski@cyberark.com>
Signed-off-by: Hubert Dąbrowski <131349909+hdabrowski@users.noreply.github.com>
Co-authored-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>
Hubert Dąbrowski 1 week ago
parent
commit
8cd018b425

+ 38 - 0
apis/externalsecrets/v1/secretstore_conjur_types.go

@@ -21,6 +21,7 @@ import esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
 // ConjurProvider provides access to a Conjur provider.
 // ConjurProvider provides access to a Conjur provider.
 type ConjurProvider struct {
 type ConjurProvider struct {
 	// URL is the endpoint of the Conjur instance.
 	// URL is the endpoint of the Conjur instance.
+	// +required
 	URL string `json:"url"`
 	URL string `json:"url"`
 
 
 	// CABundle is a PEM encoded CA bundle that will be used to validate the Conjur server certificate.
 	// CABundle is a PEM encoded CA bundle that will be used to validate the Conjur server certificate.
@@ -34,10 +35,13 @@ type ConjurProvider struct {
 	CAProvider *CAProvider `json:"caProvider,omitempty"`
 	CAProvider *CAProvider `json:"caProvider,omitempty"`
 
 
 	// Defines authentication settings for connecting to Conjur.
 	// Defines authentication settings for connecting to Conjur.
+	// +required
 	Auth ConjurAuth `json:"auth"`
 	Auth ConjurAuth `json:"auth"`
 }
 }
 
 
 // ConjurAuth is the way to provide authentication credentials to the ConjurProvider.
 // ConjurAuth is the way to provide authentication credentials to the ConjurProvider.
+// +kubebuilder:validation:MaxProperties=1
+// +kubebuilder:validation:MinProperties=1
 type ConjurAuth struct {
 type ConjurAuth struct {
 	// Authenticates with Conjur using an API key.
 	// Authenticates with Conjur using an API key.
 	// +optional
 	// +optional
@@ -46,29 +50,38 @@ type ConjurAuth struct {
 	// Jwt enables JWT authentication using Kubernetes service account tokens.
 	// Jwt enables JWT authentication using Kubernetes service account tokens.
 	// +optional
 	// +optional
 	Jwt *ConjurJWT `json:"jwt,omitempty"`
 	Jwt *ConjurJWT `json:"jwt,omitempty"`
+
+	// Cert enables certificate-based authentication using a client certificate and key.
+	// +optional
+	Cert *ConjurCert `json:"cert,omitempty"`
 }
 }
 
 
 // ConjurAPIKey contains references to a Secret resource that holds
 // ConjurAPIKey contains references to a Secret resource that holds
 // the Conjur username and API key.
 // the Conjur username and API key.
 type ConjurAPIKey struct {
 type ConjurAPIKey struct {
 	// Account is the Conjur organization account name.
 	// Account is the Conjur organization account name.
+	// +required
 	Account string `json:"account"`
 	Account string `json:"account"`
 
 
 	// A reference to a specific 'key' containing the Conjur username
 	// A reference to a specific 'key' containing the Conjur username
 	// within a Secret resource. In some instances, `key` is a required field.
 	// within a Secret resource. In some instances, `key` is a required field.
+	// +required
 	UserRef *esmeta.SecretKeySelector `json:"userRef"`
 	UserRef *esmeta.SecretKeySelector `json:"userRef"`
 
 
 	// A reference to a specific 'key' containing the Conjur API key
 	// A reference to a specific 'key' containing the Conjur API key
 	// within a Secret resource. In some instances, `key` is a required field.
 	// within a Secret resource. In some instances, `key` is a required field.
+	// +required
 	APIKeyRef *esmeta.SecretKeySelector `json:"apiKeyRef"`
 	APIKeyRef *esmeta.SecretKeySelector `json:"apiKeyRef"`
 }
 }
 
 
 // ConjurJWT defines the JWT authentication configuration for Conjur provider.
 // ConjurJWT defines the JWT authentication configuration for Conjur provider.
 type ConjurJWT struct {
 type ConjurJWT struct {
 	// Account is the Conjur organization account name.
 	// Account is the Conjur organization account name.
+	// +required
 	Account string `json:"account"`
 	Account string `json:"account"`
 
 
 	// The conjur authn jwt webservice id
 	// The conjur authn jwt webservice id
+	// +required
 	ServiceID string `json:"serviceID"`
 	ServiceID string `json:"serviceID"`
 
 
 	// Optional HostID for JWT authentication. This may be used depending
 	// Optional HostID for JWT authentication. This may be used depending
@@ -86,3 +99,28 @@ type ConjurJWT struct {
 	// +optional
 	// +optional
 	ServiceAccountRef *esmeta.ServiceAccountSelector `json:"serviceAccountRef,omitempty"`
 	ServiceAccountRef *esmeta.ServiceAccountSelector `json:"serviceAccountRef,omitempty"`
 }
 }
+
+// ConjurCert defines the Cert authentication configuration for Conjur provider.
+type ConjurCert struct {
+	// Account is the Conjur organization account name.
+	// +required
+	Account string `json:"account"`
+
+	// The conjur authn cert webservice id
+	// +required
+	ServiceID string `json:"serviceID"`
+
+	// Optional HostID for cert authentication (can be omitted when using 'spiffe' mode).
+	// +optional
+	HostID string `json:"hostId,omitempty"`
+
+	// ClientCertRef is a reference to a specific 'key' containing the client certificate
+	// within a Secret resource. The certificate must be PEM-encoded.
+	// +required
+	ClientCertRef *esmeta.SecretKeySelector `json:"clientCertRef"`
+
+	// ClientKeyRef is a reference to a specific 'key' containing the private RSA client key
+	// within a Secret resource. The key must be PEM-encoded.
+	// +required
+	ClientKeyRef *esmeta.SecretKeySelector `json:"clientKeyRef"`
+}

+ 30 - 0
apis/externalsecrets/v1/zz_generated.deepcopy.go

@@ -1244,6 +1244,11 @@ func (in *ConjurAuth) DeepCopyInto(out *ConjurAuth) {
 		*out = new(ConjurJWT)
 		*out = new(ConjurJWT)
 		(*in).DeepCopyInto(*out)
 		(*in).DeepCopyInto(*out)
 	}
 	}
+	if in.Cert != nil {
+		in, out := &in.Cert, &out.Cert
+		*out = new(ConjurCert)
+		(*in).DeepCopyInto(*out)
+	}
 }
 }
 
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConjurAuth.
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConjurAuth.
@@ -1256,6 +1261,31 @@ func (in *ConjurAuth) DeepCopy() *ConjurAuth {
 	return out
 	return out
 }
 }
 
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ConjurCert) DeepCopyInto(out *ConjurCert) {
+	*out = *in
+	if in.ClientCertRef != nil {
+		in, out := &in.ClientCertRef, &out.ClientCertRef
+		*out = new(apismetav1.SecretKeySelector)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.ClientKeyRef != nil {
+		in, out := &in.ClientKeyRef, &out.ClientKeyRef
+		*out = new(apismetav1.SecretKeySelector)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConjurCert.
+func (in *ConjurCert) DeepCopy() *ConjurCert {
+	if in == nil {
+		return nil
+	}
+	out := new(ConjurCert)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ConjurJWT) DeepCopyInto(out *ConjurJWT) {
 func (in *ConjurJWT) DeepCopyInto(out *ConjurJWT) {
 	*out = *in
 	*out = *in

+ 81 - 0
config/crds/bases/external-secrets.io_clustersecretstores.yaml

@@ -1588,6 +1588,8 @@ spec:
                       auth:
                       auth:
                         description: Defines authentication settings for connecting
                         description: Defines authentication settings for connecting
                           to Conjur.
                           to Conjur.
+                        maxProperties: 1
+                        minProperties: 1
                         properties:
                         properties:
                           apikey:
                           apikey:
                             description: Authenticates with Conjur using an API key.
                             description: Authenticates with Conjur using an API key.
@@ -1659,6 +1661,85 @@ spec:
                             - apiKeyRef
                             - apiKeyRef
                             - userRef
                             - userRef
                             type: object
                             type: object
+                          cert:
+                            description: Cert enables certificate-based authentication
+                              using a client certificate and key.
+                            properties:
+                              account:
+                                description: Account is the Conjur organization account
+                                  name.
+                                type: string
+                              clientCertRef:
+                                description: |-
+                                  ClientCertRef is a reference to a specific 'key' containing the client certificate
+                                  within a Secret resource. The certificate must be PEM-encoded.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                              clientKeyRef:
+                                description: |-
+                                  ClientKeyRef is a reference to a specific 'key' containing the private RSA client key
+                                  within a Secret resource. The key must be PEM-encoded.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                              hostId:
+                                description: Optional HostID for cert authentication
+                                  (can be omitted when using 'spiffe' mode).
+                                type: string
+                              serviceID:
+                                description: The conjur authn cert webservice id
+                                type: string
+                            required:
+                            - account
+                            - clientCertRef
+                            - clientKeyRef
+                            - serviceID
+                            type: object
                           jwt:
                           jwt:
                             description: Jwt enables JWT authentication using Kubernetes
                             description: Jwt enables JWT authentication using Kubernetes
                               service account tokens.
                               service account tokens.

+ 81 - 0
config/crds/bases/external-secrets.io_secretstores.yaml

@@ -1588,6 +1588,8 @@ spec:
                       auth:
                       auth:
                         description: Defines authentication settings for connecting
                         description: Defines authentication settings for connecting
                           to Conjur.
                           to Conjur.
+                        maxProperties: 1
+                        minProperties: 1
                         properties:
                         properties:
                           apikey:
                           apikey:
                             description: Authenticates with Conjur using an API key.
                             description: Authenticates with Conjur using an API key.
@@ -1659,6 +1661,85 @@ spec:
                             - apiKeyRef
                             - apiKeyRef
                             - userRef
                             - userRef
                             type: object
                             type: object
+                          cert:
+                            description: Cert enables certificate-based authentication
+                              using a client certificate and key.
+                            properties:
+                              account:
+                                description: Account is the Conjur organization account
+                                  name.
+                                type: string
+                              clientCertRef:
+                                description: |-
+                                  ClientCertRef is a reference to a specific 'key' containing the client certificate
+                                  within a Secret resource. The certificate must be PEM-encoded.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                              clientKeyRef:
+                                description: |-
+                                  ClientKeyRef is a reference to a specific 'key' containing the private RSA client key
+                                  within a Secret resource. The key must be PEM-encoded.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                              hostId:
+                                description: Optional HostID for cert authentication
+                                  (can be omitted when using 'spiffe' mode).
+                                type: string
+                              serviceID:
+                                description: The conjur authn cert webservice id
+                                type: string
+                            required:
+                            - account
+                            - clientCertRef
+                            - clientKeyRef
+                            - serviceID
+                            type: object
                           jwt:
                           jwt:
                             description: Jwt enables JWT authentication using Kubernetes
                             description: Jwt enables JWT authentication using Kubernetes
                               service account tokens.
                               service account tokens.

+ 152 - 0
deploy/crds/bundle.yaml

@@ -3804,6 +3804,8 @@ spec:
                       properties:
                       properties:
                         auth:
                         auth:
                           description: Defines authentication settings for connecting to Conjur.
                           description: Defines authentication settings for connecting to Conjur.
+                          maxProperties: 1
+                          minProperties: 1
                           properties:
                           properties:
                             apikey:
                             apikey:
                               description: Authenticates with Conjur using an API key.
                               description: Authenticates with Conjur using an API key.
@@ -3872,6 +3874,80 @@ spec:
                                 - apiKeyRef
                                 - apiKeyRef
                                 - userRef
                                 - userRef
                               type: object
                               type: object
+                            cert:
+                              description: Cert enables certificate-based authentication using a client certificate and key.
+                              properties:
+                                account:
+                                  description: Account is the Conjur organization account name.
+                                  type: string
+                                clientCertRef:
+                                  description: |-
+                                    ClientCertRef is a reference to a specific 'key' containing the client certificate
+                                    within a Secret resource. The certificate must be PEM-encoded.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                                clientKeyRef:
+                                  description: |-
+                                    ClientKeyRef is a reference to a specific 'key' containing the private RSA client key
+                                    within a Secret resource. The key must be PEM-encoded.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                                hostId:
+                                  description: Optional HostID for cert authentication (can be omitted when using 'spiffe' mode).
+                                  type: string
+                                serviceID:
+                                  description: The conjur authn cert webservice id
+                                  type: string
+                              required:
+                                - account
+                                - clientCertRef
+                                - clientKeyRef
+                                - serviceID
+                              type: object
                             jwt:
                             jwt:
                               description: Jwt enables JWT authentication using Kubernetes service account tokens.
                               description: Jwt enables JWT authentication using Kubernetes service account tokens.
                               properties:
                               properties:
@@ -16581,6 +16657,8 @@ spec:
                       properties:
                       properties:
                         auth:
                         auth:
                           description: Defines authentication settings for connecting to Conjur.
                           description: Defines authentication settings for connecting to Conjur.
+                          maxProperties: 1
+                          minProperties: 1
                           properties:
                           properties:
                             apikey:
                             apikey:
                               description: Authenticates with Conjur using an API key.
                               description: Authenticates with Conjur using an API key.
@@ -16649,6 +16727,80 @@ spec:
                                 - apiKeyRef
                                 - apiKeyRef
                                 - userRef
                                 - userRef
                               type: object
                               type: object
+                            cert:
+                              description: Cert enables certificate-based authentication using a client certificate and key.
+                              properties:
+                                account:
+                                  description: Account is the Conjur organization account name.
+                                  type: string
+                                clientCertRef:
+                                  description: |-
+                                    ClientCertRef is a reference to a specific 'key' containing the client certificate
+                                    within a Secret resource. The certificate must be PEM-encoded.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                                clientKeyRef:
+                                  description: |-
+                                    ClientKeyRef is a reference to a specific 'key' containing the private RSA client key
+                                    within a Secret resource. The key must be PEM-encoded.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                                hostId:
+                                  description: Optional HostID for cert authentication (can be omitted when using 'spiffe' mode).
+                                  type: string
+                                serviceID:
+                                  description: The conjur authn cert webservice id
+                                  type: string
+                              required:
+                                - account
+                                - clientCertRef
+                                - clientKeyRef
+                                - serviceID
+                              type: object
                             jwt:
                             jwt:
                               description: Jwt enables JWT authentication using Kubernetes service account tokens.
                               description: Jwt enables JWT authentication using Kubernetes service account tokens.
                               properties:
                               properties:

+ 95 - 0
docs/api/spec.md

@@ -3268,6 +3268,101 @@ ConjurJWT
 <p>Jwt enables JWT authentication using Kubernetes service account tokens.</p>
 <p>Jwt enables JWT authentication using Kubernetes service account tokens.</p>
 </td>
 </td>
 </tr>
 </tr>
+<tr>
+<td>
+<code>cert</code></br>
+<em>
+<a href="#external-secrets.io/v1.ConjurCert">
+ConjurCert
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Cert enables certificate-based authentication using a client certificate and key.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1.ConjurCert">ConjurCert
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.ConjurAuth">ConjurAuth</a>)
+</p>
+<p>
+<p>ConjurCert defines the Cert authentication configuration for Conjur provider.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>account</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Account is the Conjur organization account name.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>serviceID</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>The conjur authn cert webservice id</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>hostId</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Optional HostID for cert authentication (can be omitted when using &lsquo;spiffe&rsquo; mode).</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>clientCertRef</code></br>
+<em>
+<a href="https://pkg.go.dev/github.com/external-secrets/external-secrets/apis/meta/v1#SecretKeySelector">
+External Secrets meta/v1.SecretKeySelector
+</a>
+</em>
+</td>
+<td>
+<p>ClientCertRef is a reference to a specific &lsquo;key&rsquo; containing the client certificate
+within a Secret resource. The certificate must be PEM-encoded.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>clientKeyRef</code></br>
+<em>
+<a href="https://pkg.go.dev/github.com/external-secrets/external-secrets/apis/meta/v1#SecretKeySelector">
+External Secrets meta/v1.SecretKeySelector
+</a>
+</em>
+</td>
+<td>
+<p>ClientKeyRef is a reference to a specific &lsquo;key&rsquo; containing the private RSA client key
+within a Secret resource. The key must be PEM-encoded.</p>
+</td>
+</tr>
 </tbody>
 </tbody>
 </table>
 </table>
 <h3 id="external-secrets.io/v1.ConjurJWT">ConjurJWT
 <h3 id="external-secrets.io/v1.ConjurJWT">ConjurJWT

+ 56 - 3
docs/provider/conjur.md

@@ -8,8 +8,8 @@ Before installing the Secrets Manager provider, you need:
 
 
 * A running instance of [Conjur OSS](https://github.com/cyberark/conjur) or CyberArk Secrets Manager, with:
 * A running instance of [Conjur OSS](https://github.com/cyberark/conjur) or CyberArk Secrets Manager, with:
   * An accessible Secrets Manager endpoint (for example: `https://myapi.example.com`).
   * An accessible Secrets Manager endpoint (for example: `https://myapi.example.com`).
-  * Your configured Secrets Manager authentication info (such as `hostid`, `apikey`, or JWT service ID). For more information on configuring Secrets Manager, see [Policy statement reference](https://docs.cyberark.com/conjur-open-source/Latest/en/Content/Operations/Policy/policy-statement-ref.htm).
-  * Support for your authentication method (`apikey` is supported by default, `jwt` requires additional configuration).
+  * Your configured Secrets Manager authentication info (such as `hostid`, `apikey` or service ID of JWT or Cert authenticator). For more information on configuring Secrets Manager, see [Policy statement reference](https://docs.cyberark.com/conjur-open-source/Latest/en/Content/Operations/Policy/policy-statement-ref.htm).
+  * Support for your authentication method (`apikey` is supported by default, `jwt` and `cert` require additional configuration).
   * **Optional**: Secrets Manager server certificate (see [below](#conjur-server-certificate)).
   * **Optional**: Secrets Manager server certificate (see [below](#conjur-server-certificate)).
 * A Kubernetes cluster with ESO installed.
 * A Kubernetes cluster with ESO installed.
 
 
@@ -23,10 +23,11 @@ If you set up your Secrets Manager server with a self-signed certificate, we rec
 
 
 ## External secret store
 ## External secret store
 
 
-The Secrets Manager provider is configured as an external secret store in ESO. The Secrets Manager provider supports these two methods to authenticate to Secrets Manager:
+To synchronise secrets with Secrets Manager, you must configure an external secret store with one of the following three methods to authenticate to the Secrets Manager API:
 
 
 * [`apikey`](#option-1-external-secret-store-with-apikey-authentication): uses a Secrets Manager `hostid` and `apikey` to authenticate with Secrets Manager
 * [`apikey`](#option-1-external-secret-store-with-apikey-authentication): uses a Secrets Manager `hostid` and `apikey` to authenticate with Secrets Manager
 * [`jwt`](#option-2-external-secret-store-with-jwt-authentication): uses a JWT to authenticate with Secrets Manager
 * [`jwt`](#option-2-external-secret-store-with-jwt-authentication): uses a JWT to authenticate with Secrets Manager
+* [`cert`](#option-3-external-secret-store-with-cert-authentication): uses a client certificate and private key to authenticate with Secrets Manager
 
 
 ### Option 1: External secret store with apiKey authentication
 ### Option 1: External secret store with apiKey authentication
 
 
@@ -132,6 +133,58 @@ kubectl apply -n external-secrets -f conjur-secret-store.yaml
 # kubectl delete secretstore -n external-secrets conjur
 # kubectl delete secretstore -n external-secrets conjur
 ```
 ```
 
 
+### Option 3: External secret store with cert authentication
+
+This method uses a client X.509 certificate and private key to authenticate with Secrets Manager via the `authn-cert` authenticator. The certificate must be issued by a CA that is trusted by the Secrets Manager `authn-cert` service. Conjur validates the presented certificate using constraints configured in the authenticator policy (such as `cn`, `san-uri`, `san-dns`, `san-ip` variables), and optionally per-host annotations that scope constraints to individual workloads.
+
+For more information on configuring the certificate authenticator, see the [Secrets Manager Certificate authenticator documentation](https://docs.cyberark.com/conjur-open-source/latest/en/content/operations/authn/authn-cert/authn-cert.htm?TocPath=Integrations%7CCertificate%20authenticator%7C_____0).
+
+#### Step 1: Define an external secret store
+
+When you use cert authentication, the following must be specified in the `SecretStore`:
+
+* `account` - The name of the Secrets Manager account
+* `serviceID` - The ID of the `authn-cert` `WebService` configured in Secrets Manager that is used to authenticate the client certificate
+* `clientCertRef` - Reference to a Kubernetes secret containing the PEM-encoded client certificate
+* `clientKeyRef` - Reference to a Kubernetes secret containing the PEM-encoded client private key
+* `hostId` (optional) - The Secrets Manager host identity to authenticate as. Required in the default `request` mode. Leave empty when using `spiffe` mode, where the host identity is derived from the SPIFFE SAN URI (`spiffe://<trust-domain>/<workload-id>`) in the certificate.
+
+```yaml
+{% include 'conjur-secret-store-cert.yaml' %}
+```
+
+#### Step 2: Create Kubernetes secrets for the client certificate and key
+
+To connect to the Secrets Manager server using certificate authentication, the **ESO Secrets Manager provider** needs to retrieve the client certificate and private key from K8s secrets.
+
+You can use either a TLS-typed secret or a generic secret. The following example uses a generic secret:
+
+```shell
+# This is all one line
+kubectl -n external-secrets create secret generic conjur-client-cert \
+  --from-file=tls.crt=/path/to/client.crt \
+  --from-file=tls.key=/path/to/client.key
+```
+
+!!! Note
+    `conjur-client-cert` is the `name` defined in the `clientCertRef` and `clientKeyRef` fields of the `conjur-secret-store.yaml` file. Both the certificate and the key must be PEM-encoded.
+
+#### Step 3: Create the external secrets store
+
+!!! Important
+    Unless you are using a [ClusterSecretStore](../api/clustersecretstore.md), credentials must reside in the same namespace as the SecretStore.
+
+```shell
+# WARNING: creates the store in the "external-secrets" namespace, update the value as needed
+#
+kubectl apply -n external-secrets -f conjur-secret-store.yaml
+
+# WARNING: running the delete command will delete the secret store configuration
+#
+# If there is a need to delete the external secretstore
+# kubectl delete secretstore -n external-secrets conjur
+```
+
 ## Define an external secret
 ## Define an external secret
 
 
 After you have configured the Secrets Manager provider secret store, you can fetch secrets from Secrets Manager.
 After you have configured the Secrets Manager provider secret store, you can fetch secrets from Secrets Manager.

+ 28 - 0
docs/snippets/conjur-secret-store-cert.yaml

@@ -0,0 +1,28 @@
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: conjur
+spec:
+  provider:
+    conjur:
+      # Service URL
+      url: https://myapi.conjur.org
+      # [OPTIONAL] base64 encoded string of certificate
+      caBundle: OPTIONALxFIELDxxxBase64xCertxString==
+      auth:
+        cert:
+          # conjur account
+          account: conjur
+          # The authn-cert service ID
+          serviceID: my-cert-auth-service
+          # [OPTIONAL] HostID for cert authentication (leave empty for 'spiffe' mode).
+          # hostId: vm-01
+          # Reference to a Kubernetes secret containing the PEM-encoded client certificate
+          clientCertRef:
+            name: conjur-client-cert
+            key: tls.crt
+          # Reference to a Kubernetes secret containing the PEM-encoded client key
+          clientKeyRef:
+            name: conjur-client-cert
+            key: tls.key
+

+ 1 - 1
e2e/Makefile

@@ -68,7 +68,7 @@ test.managed: e2e-image ## Run e2e tests against current kube context
 
 
 
 
 e2e-bin: install-ginkgo
 e2e-bin: install-ginkgo
-	   CGO_ENABLED=0 ginkgo build ./suites/...
+	   CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ginkgo build ./suites/...
 
 
 e2e-image: e2e-bin
 e2e-image: e2e-bin
 	-rm -rf ./k8s/deploy
 	-rm -rf ./k8s/deploy

+ 432 - 16
e2e/framework/addon/conjur.go

@@ -17,17 +17,27 @@ limitations under the License.
 package addon
 package addon
 
 
 import (
 import (
+	"context"
 	"crypto/rand"
 	"crypto/rand"
+	"crypto/rsa"
 	"crypto/x509"
 	"crypto/x509"
+	"crypto/x509/pkix"
 	"encoding/base64"
 	"encoding/base64"
 	"encoding/json"
 	"encoding/json"
 	"encoding/pem"
 	"encoding/pem"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"math/big"
+	"net/url"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
+	"time"
 
 
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/wait"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
 
 
 	// nolint
 	// nolint
 
 
@@ -38,6 +48,31 @@ import (
 	"github.com/external-secrets/external-secrets-e2e/framework/util"
 	"github.com/external-secrets/external-secrets-e2e/framework/util"
 )
 )
 
 
+const (
+	// authenticatorServicePort is the port the authenticator-service binary listens on.
+	// This matches the CONJUR_AUTHENTICATOR_SERVICE_URL default of http://localhost:5681
+	// and must not collide with the conjur-oss container's own PORT (8080), since both
+	// containers share the pod's network namespace.
+	authenticatorServicePort = "5681"
+
+	// authenticatorServiceContainerName is the name of the sidecar container running
+	// the authenticator-service binary inside the conjur-oss pod.
+	authenticatorServiceContainerName = "authenticator-service"
+
+	// SpiffeTrustDomain is the SPIFFE trust domain used for the authn-cert authenticator
+	// operating in 'spiffe' host mode.
+	SpiffeTrustDomain = "eso-tests.example.org"
+
+	// SpiffeWorkloadID is the SPIFFE ID's workload path, embedded as a URI SAN in the
+	// SPIFFE client certificate (spiffe://<trust-domain>/<SpiffeWorkloadID>).
+	SpiffeWorkloadID = "vm-spiffe"
+
+	// SpiffeIdentityPath is the Conjur policy path that the authn-cert authenticator
+	// prepends to the SPIFFE workload path to derive the full host identity. The resulting
+	// host must exist at policy path SpiffeIdentityPath/SpiffeWorkloadID.
+	SpiffeIdentityPath = "eso-tests/spiffe"
+)
+
 type Conjur struct {
 type Conjur struct {
 	chart        *HelmChart
 	chart        *HelmChart
 	dataKey      string
 	dataKey      string
@@ -48,6 +83,10 @@ type Conjur struct {
 
 
 	AdminApiKey    string
 	AdminApiKey    string
 	ConjurServerCA []byte
 	ConjurServerCA []byte
+	ClientCert     []byte
+	ClientKey      []byte
+	SpiffeCert     []byte
+	SpiffeKey      []byte
 	portForwarder  *PortForward
 	portForwarder  *PortForward
 }
 }
 
 
@@ -60,14 +99,54 @@ func NewConjur() *Conjur {
 		Fail(err.Error())
 		Fail(err.Error())
 	}
 	}
 
 
+	// Generate client certificates for cert-based authentication.
+	// The CN "vm-01" matches the host identity in the cert host policy.
+	rootBlock, _ := pem.Decode(rootPem)
+	if rootBlock == nil {
+		Fail("unable to decode root cert PEM")
+	}
+	rootCert, err := x509.ParseCertificate(rootBlock.Bytes)
+	if err != nil {
+		Fail(fmt.Sprintf("unable to parse root cert: %v", err))
+	}
+	rootKeyBlock, _ := pem.Decode(rootKeyPEM)
+	if rootKeyBlock == nil {
+		Fail("unable to decode root key PEM")
+	}
+	rootKey, err := x509.ParsePKCS1PrivateKey(rootKeyBlock.Bytes)
+	if err != nil {
+		Fail(fmt.Sprintf("unable to parse root key: %v", err))
+	}
+	clientCertPem, clientKeyRSA, err := genClientAuthCert(rootCert, rootKey, "vm-01", "")
+	if err != nil {
+		Fail(fmt.Sprintf("unable to generate client cert: %v", err))
+	}
+	clientKeyPem := pem.EncodeToMemory(&pem.Block{
+		Type:  privatePemType,
+		Bytes: x509.MarshalPKCS1PrivateKey(clientKeyRSA),
+	})
+
+	// Generate a second client certificate carrying a SPIFFE URI SAN, used by the
+	// authn-cert authenticator's 'spiffe' host mode, where the authenticated host's
+	// identity is derived from the certificate itself rather than the request path.
+	spiffeCertPem, spiffeKeyRSA, err := genClientAuthCert(rootCert, rootKey, "",
+		fmt.Sprintf("spiffe://%s/%s", SpiffeTrustDomain, SpiffeWorkloadID))
+	if err != nil {
+		Fail(fmt.Sprintf("unable to generate spiffe client cert: %v", err))
+	}
+	spiffeKeyPem := pem.EncodeToMemory(&pem.Block{
+		Type:  privatePemType,
+		Bytes: x509.MarshalPKCS1PrivateKey(spiffeKeyRSA),
+	})
+
 	return &Conjur{
 	return &Conjur{
 		dataKey: dataKey,
 		dataKey: dataKey,
 		chart: &HelmChart{
 		chart: &HelmChart{
 			Namespace:   "conjur",
 			Namespace:   "conjur",
 			ReleaseName: "conjur-conjur",
 			ReleaseName: "conjur-conjur",
 			Chart:       fmt.Sprintf("%s/conjur-oss", repo),
 			Chart:       fmt.Sprintf("%s/conjur-oss", repo),
-			// Use latest version of Conjur OSS. To pin to a specific version, uncomment the following line.
-			// ChartVersion: "2.0.7",
+			// Pinned to the current latest conjur-oss chart release. Bump alongside conjurReleaseVersion.
+			ChartVersion: "2.1.1",
 			Repo: ChartRepo{
 			Repo: ChartRepo{
 				Name: repo,
 				Name: repo,
 				URL:  "https://cyberark.github.io/helm-charts",
 				URL:  "https://cyberark.github.io/helm-charts",
@@ -87,27 +166,102 @@ func NewConjur() *Conjur {
 				},
 				},
 			},
 			},
 		},
 		},
-		Namespace: "conjur",
+		Namespace:  "conjur",
+		ClientCert: clientCertPem,
+		ClientKey:  clientKeyPem,
+		SpiffeCert: spiffeCertPem,
+		SpiffeKey:  spiffeKeyPem,
 	}
 	}
 }
 }
 
 
+// Install sets up the conjur-oss chart, the authenticator-service sidecar, and configures
+// the authenticators. The install-then-patch-then-exec sequence spans several kind/kubelet
+// eventually-consistent steps (Deployment rollout, pod scheduling, kubelet exec/attach
+// readiness); under load these can intermittently race even with generous waits at each
+// step (e.g. a pod reported Ready via the API can still be replaced by a subsequent
+// reconcile before it is actually exec-attachable). Rather than chase every individual
+// timing window, retry the whole sequence a few times, tearing down between attempts.
 func (l *Conjur) Install() error {
 func (l *Conjur) Install() error {
-	err := l.chart.Install()
-	if err != nil {
+	const maxAttempts = 3
+	var lastErr error
+	for attempt := 1; attempt <= maxAttempts; attempt++ {
+		if attempt > 1 {
+			By(fmt.Sprintf("retrying conjur install (attempt %d/%d) after: %v", attempt, maxAttempts, lastErr))
+			if err := l.teardown(); err != nil {
+				return fmt.Errorf("unable to tear down conjur before retry: %w", err)
+			}
+		}
+
+		if lastErr = l.installOnce(); lastErr == nil {
+			return nil
+		}
+	}
+	return fmt.Errorf("conjur install failed after %d attempts: %w", maxAttempts, lastErr)
+}
+
+func (l *Conjur) installOnce() error {
+	if err := l.chart.Install(); err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	err = l.initConjur()
-	if err != nil {
+	if err := l.patchAuthenticatorServiceSidecar(); err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	err = l.configureConjur()
-	if err != nil {
+	if err := l.initConjur(); err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return nil
+	return l.configureConjur()
+}
+
+// teardown removes the conjur-oss release and namespace so installOnce can run again from
+// a clean slate. Errors are ignored: the namespace may not exist yet if chart.Install()
+// itself failed on a previous attempt.
+func (l *Conjur) teardown() error {
+	if l.portForwarder != nil {
+		l.portForwarder.Close()
+		l.portForwarder = nil
+	}
+	_ = l.chart.Uninstall()
+	err := l.chart.config.KubeClientSet.CoreV1().Namespaces().Delete(GinkgoT().Context(), l.chart.Namespace, metav1.DeleteOptions{})
+	if err != nil {
+		return nil //nolint:nilerr // namespace may already be gone; nothing to clean up.
+	}
+	return util.WaitForKubeNamespaceNotExist(l.chart.Namespace, l.chart.config.KubeClientSet)
+}
+
+// retryExecCmd retries util.ExecCmdWithContainer briefly to absorb the window where a
+// freshly-created pod reports Ready via the API before the kubelet's exec/attach endpoint
+// for its containers is actually available.
+func retryExecCmd(client kubernetes.Interface, config *rest.Config, podName, containerName, namespace, command string) error {
+	var lastErr error
+	for i := 0; i < 10; i++ {
+		if i > 0 {
+			time.Sleep(1 * time.Second)
+		}
+		if _, lastErr = util.ExecCmdWithContainer(client, config, podName, containerName, namespace, command); lastErr == nil {
+			return nil
+		}
+	}
+	return lastErr
+}
+
+// waitForConjurServiceReady polls the Conjur Service URL from inside the cluster (via exec
+// into the conjur-oss pod) until it gets a real HTTP response, absorbing kube-proxy
+// Endpoints-sync lag after the sidecar rollout. curl's own TLS verification is disabled
+// (-k) because this only checks that the Service routes to a live, responsive nginx; actual
+// certificate validation is exercised by the real authentication flows later.
+func (l *Conjur) waitForConjurServiceReady() error {
+	cmd := fmt.Sprintf("curl -sk -o /dev/null -w '%%{http_code}' %s/status", l.ConjurURL)
+	return wait.PollUntilContextTimeout(GinkgoT().Context(), 1*time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) {
+		out, err := util.ExecCmdWithContainer(l.chart.config.KubeClientSet, l.chart.config.KubeConfig,
+			l.PodName, "conjur-oss", l.Namespace, cmd)
+		if err != nil {
+			return false, nil
+		}
+		return strings.Contains(out, "200"), nil
+	})
 }
 }
 
 
 func (l *Conjur) initConjur() error {
 func (l *Conjur) initConjur() error {
@@ -128,12 +282,12 @@ func (l *Conjur) initConjur() error {
 	}
 	}
 	l.ConjurServerCA = caCertSecret.Data["tls.crt"]
 	l.ConjurServerCA = caCertSecret.Data["tls.crt"]
 
 
-	// Create "default" account
-	_, err = util.ExecCmdWithContainer(
-		l.chart.config.KubeClientSet,
-		l.chart.config.KubeConfig,
-		l.PodName, "conjur-oss", l.Namespace, "conjurctl account create default")
-	if err != nil {
+	// Create "default" account. A freshly-created pod can report Ready in the API before
+	// the kubelet's exec/attach endpoint for its containers is actually wired up, so the
+	// first exec attempt against it can fail with "container not found" even though the
+	// container is running; retry briefly to absorb that race.
+	if err := retryExecCmd(l.chart.config.KubeClientSet, l.chart.config.KubeConfig,
+		l.PodName, "conjur-oss", l.Namespace, "conjurctl account create default"); err != nil {
 		return fmt.Errorf("error initializing conjur: %w", err)
 		return fmt.Errorf("error initializing conjur: %w", err)
 	}
 	}
 
 
@@ -162,6 +316,18 @@ func (l *Conjur) initConjur() error {
 	}
 	}
 
 
 	l.ConjurURL = fmt.Sprintf("https://conjur-conjur-conjur-oss.%s.svc.cluster.local", l.Namespace)
 	l.ConjurURL = fmt.Sprintf("https://conjur-conjur-conjur-oss.%s.svc.cluster.local", l.Namespace)
+
+	// The ExternalSecret controller reaches Conjur through this Service URL, which routes
+	// through kube-proxy to whichever pod is currently in the Service's Endpoints. That
+	// Endpoints list is reconciled asynchronously and can lag behind the pod-level readiness
+	// checks in patchAuthenticatorServiceSidecar's rollout wait, especially right after the
+	// sidecar-triggered rolling update. Probe through the Service URL itself (from inside the
+	// cluster, exercising the same DNS/ClusterIP/kube-proxy path the controller will use)
+	// before proceeding, so tests don't race a stale or not-yet-synced endpoint.
+	if err := l.waitForConjurServiceReady(); err != nil {
+		return fmt.Errorf("error waiting for conjur service to be reachable: %w", err)
+	}
+
 	cfg := conjurapi.Config{
 	cfg := conjurapi.Config{
 		Account:      "default",
 		Account:      "default",
 		ApplianceURL: fmt.Sprintf("https://localhost:%d", l.portForwarder.localPort),
 		ApplianceURL: fmt.Sprintf("https://localhost:%d", l.portForwarder.localPort),
@@ -179,6 +345,164 @@ func (l *Conjur) initConjur() error {
 	return nil
 	return nil
 }
 }
 
 
+// conjurReleaseVersion is the pinned cyberark/conjur GitHub release used to fetch the
+// authenticator-service binary. Bump this when a newer release is needed.
+const conjurReleaseVersion = "v1.27.0"
+
+// authenticatorServiceInitScript downloads the authenticator-service binary matching the
+// node's architecture from the pinned cyberark/conjur GitHub release (see
+// conjurReleaseVersion), and writes its minimal config file, into a shared emptyDir volume
+// mounted by the sidecar container.
+//
+// The authenticator-service is not distributed as a container image, only as raw
+// per-architecture binaries attached to GitHub releases (see AUTHENTICATOR_SERVICE.md in
+// the cyberark/conjur repo), so it must be fetched at runtime rather than referenced by tag.
+const authenticatorServiceInitScript = `set -e
+ARCH=$(uname -m)
+case "$ARCH" in
+  x86_64) BIN_ARCH=amd64 ;;
+  aarch64|arm64) BIN_ARCH=arm64 ;;
+  *) echo "unsupported architecture: $ARCH" >&2; exit 1 ;;
+esac
+DOWNLOAD_URL=$(curl -s https://api.github.com/repos/cyberark/conjur/releases/tags/` + conjurReleaseVersion + `\
+  | grep -o "\"browser_download_url\": *\"[^\"]*authenticator_linux_[0-9]*_${BIN_ARCH}\"" \
+  | head -1 | sed -E 's/.*"(https:[^"]+)".*/\1/')
+if [ -z "$DOWNLOAD_URL" ]; then
+  echo "unable to determine authenticator-service download URL for arch ${BIN_ARCH}" >&2
+  exit 1
+fi
+echo "downloading authenticator-service from ${DOWNLOAD_URL}"
+curl -sL -o /authsvc/authenticator-service "$DOWNLOAD_URL"
+chmod +x /authsvc/authenticator-service
+printf '{"port": "%s", "http_timeout": "10s"}' "` + authenticatorServicePort + `" > /authsvc/config.json`
+
+// patchAuthenticatorServiceSidecar adds an authenticator-service sidecar to the conjur-oss
+// deployment and enables the feature flag that lets Conjur delegate authn-cert credential
+// validation to it. Conjur OSS's authn-cert authenticator does not validate client
+// certificates itself; it calls out to this separate HTTP service (see
+// AUTHENTICATOR_SERVICE.md and CERTIFICATE_AUTH.md in the cyberark/conjur repo). The
+// conjur-oss Helm chart has no hook for adding sidecars, so the running Deployment is
+// patched directly after the initial chart install.
+func (l *Conjur) patchAuthenticatorServiceSidecar() error {
+	By("patching conjur-oss deployment with authenticator-service sidecar")
+	clientSet := l.chart.config.KubeClientSet
+	deploymentName := fmt.Sprintf("%s-conjur-oss", l.chart.ReleaseName)
+
+	deployment, err := clientSet.AppsV1().Deployments(l.Namespace).Get(GinkgoT().Context(), deploymentName, metav1.GetOptions{})
+	if err != nil {
+		return fmt.Errorf("unable to get conjur-oss deployment: %w", err)
+	}
+
+	binVolume := corev1.Volume{
+		Name:         "authsvc-bin",
+		VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
+	}
+	binVolumeMount := corev1.VolumeMount{
+		Name:      binVolume.Name,
+		MountPath: "/authsvc",
+	}
+
+	deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, binVolume)
+	deployment.Spec.Template.Spec.InitContainers = append(deployment.Spec.Template.Spec.InitContainers, corev1.Container{
+		Name:         "authenticator-service-fetch",
+		Image:        "curlimages/curl:8.11.0",
+		Command:      []string{"/bin/sh", "-c"},
+		Args:         []string{authenticatorServiceInitScript},
+		VolumeMounts: []corev1.VolumeMount{binVolumeMount},
+	})
+	deployment.Spec.Template.Spec.Containers = append(deployment.Spec.Template.Spec.Containers, corev1.Container{
+		Name:         authenticatorServiceContainerName,
+		Image:        "gcr.io/distroless/static-debian12:latest",
+		Command:      []string{"/authsvc/authenticator-service"},
+		Args:         []string{"-c", "/authsvc/config.json"},
+		VolumeMounts: []corev1.VolumeMount{binVolumeMount},
+		Ports: []corev1.ContainerPort{
+			{Name: "authsvc", ContainerPort: 5681},
+		},
+	})
+
+	for i := range deployment.Spec.Template.Spec.Containers {
+		c := &deployment.Spec.Template.Spec.Containers[i]
+		if c.Name != "conjur-oss" {
+			continue
+		}
+		c.Env = append(c.Env,
+			corev1.EnvVar{Name: "CONJUR_FEATURE_AUTHENTICATOR_SERVICE_ENABLED", Value: "true"},
+			corev1.EnvVar{Name: "CONJUR_AUTHENTICATOR_SERVICE_URL", Value: "http://localhost:" + authenticatorServicePort},
+		)
+	}
+
+	updated, err := clientSet.AppsV1().Deployments(l.Namespace).Update(GinkgoT().Context(), deployment, metav1.UpdateOptions{})
+	if err != nil {
+		return fmt.Errorf("unable to patch conjur-oss deployment with authenticator-service sidecar: %w", err)
+	}
+
+	// Wait for the rollout to fully complete (old ReplicaSet scaled to 0) rather than just
+	// waiting for any pod matching the label to be ready. During the rolling update, the
+	// old (pre-sidecar) pod can remain Ready while terminating; a label-only wait can race
+	// and hand initConjur a pod whose conjur-oss container is already gone.
+	if err := waitForDeploymentRollout(clientSet, l.Namespace, deploymentName, updated.Generation); err != nil {
+		return fmt.Errorf("error waiting for conjur-oss rollout after sidecar patch: %w", err)
+	}
+
+	return nil
+}
+
+func waitForDeploymentRollout(clientSet kubernetes.Interface, namespace, name string, generation int64) error {
+	if err := wait.PollUntilContextTimeout(GinkgoT().Context(), 1*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) {
+		dep, err := clientSet.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{})
+		if err != nil {
+			return false, nil
+		}
+		if dep.Status.ObservedGeneration < generation {
+			return false, nil
+		}
+		replicas := int32(1)
+		if dep.Spec.Replicas != nil {
+			replicas = *dep.Spec.Replicas
+		}
+		return dep.Status.UpdatedReplicas == replicas &&
+			dep.Status.Replicas == replicas &&
+			dep.Status.AvailableReplicas == replicas, nil
+	}); err != nil {
+		return err
+	}
+
+	// Deployment status is computed asynchronously by the deployment controller and can
+	// briefly report AvailableReplicas==1 while the old pre-sidecar pod is still
+	// Running-but-terminating (Kubelet has not removed it yet). Cross-check pod state
+	// directly: require exactly one pod for this Deployment that is not terminating and
+	// has all 3 containers (conjur-oss, nginx, authenticator-service) ready, so
+	// initConjur never execs into a pod that's mid-teardown.
+	return wait.PollUntilContextTimeout(GinkgoT().Context(), 1*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) {
+		pods, err := clientSet.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: "app=conjur-oss"})
+		if err != nil {
+			return false, nil
+		}
+		live := 0
+		for i := range pods.Items {
+			pod := &pods.Items[i]
+			if pod.DeletionTimestamp != nil {
+				continue
+			}
+			if pod.Status.Phase != corev1.PodRunning {
+				continue
+			}
+			if len(pod.Status.ContainerStatuses) != 3 {
+				continue
+			}
+			allReady := true
+			for _, cs := range pod.Status.ContainerStatuses {
+				allReady = allReady && cs.Ready
+			}
+			if allReady {
+				live++
+			}
+		}
+		return live == 1, nil
+	})
+}
+
 func (l *Conjur) configureConjur() error {
 func (l *Conjur) configureConjur() error {
 	By("configuring conjur")
 	By("configuring conjur")
 	// Construct Conjur policy for authn-jwt. This uses the token-app-property "sub" to
 	// Construct Conjur policy for authn-jwt. This uses the token-app-property "sub" to
@@ -215,6 +539,54 @@ func (l *Conjur) configureConjur() error {
 		return fmt.Errorf("unable to load authn-jwt policy: %w", err)
 		return fmt.Errorf("unable to load authn-jwt policy: %w", err)
 	}
 	}
 
 
+	// Construct Conjur policy for two authn-cert authenticators. Both delegate client
+	// certificate validation to the authenticator-service sidecar (see
+	// patchAuthenticatorServiceSidecar). The 'ca-cert' variable name is fixed by the
+	// authenticator; it is not a policy-defined path segment.
+	//
+	// 'eso-tests' operates in 'spiffe' host mode: the authenticated host's identity is
+	// derived from the SPIFFE URI SAN in the client certificate (see genSpiffeCert), rather
+	// than from a role identifier in the request path. This is the mode exercised when the
+	// ExternalSecret store omits Auth.Cert.HostID.
+	//
+	// 'eso-tests-hostid' operates in the default 'request' host mode, mirroring the
+	// authn-jwt/eso-tests-hostid authenticator: the host identity is supplied explicitly via
+	// Auth.Cert.HostID. A single authenticator cannot serve both modes because host-mode is
+	// a per-authenticator setting, not a per-request one.
+	policy = `- !policy
+  id: conjur/authn-cert/eso-tests
+  body:
+    - !webservice
+    - !variable ca-cert
+    - !variable host-mode
+    - !variable trust-domain
+    - !variable identity-path
+
+- !policy
+  id: conjur/authn-cert/eso-tests-hostid
+  body:
+    - !webservice
+    - !variable ca-cert`
+
+	_, err = l.ConjurClient.LoadPolicy(conjurapi.PolicyModePost, "root", strings.NewReader(policy))
+	if err != nil {
+		return fmt.Errorf("unable to load authn-cert policy: %w", err)
+	}
+
+	// Set the CA certificate and spiffe-mode variables for the authn-cert authenticators
+	certSecrets := map[string]string{
+		"conjur/authn-cert/eso-tests/ca-cert":        string(l.ConjurServerCA),
+		"conjur/authn-cert/eso-tests/host-mode":      "spiffe",
+		"conjur/authn-cert/eso-tests/trust-domain":   SpiffeTrustDomain,
+		"conjur/authn-cert/eso-tests/identity-path":  SpiffeIdentityPath,
+		"conjur/authn-cert/eso-tests-hostid/ca-cert": string(l.ConjurServerCA),
+	}
+	for secretPath, secretValue := range certSecrets {
+		if err := l.ConjurClient.AddSecret(secretPath, secretValue); err != nil {
+			return fmt.Errorf("unable to add secret %s: %w", secretPath, err)
+		}
+	}
+
 	// Fetch the jwks info from the k8s cluster
 	// Fetch the jwks info from the k8s cluster
 	pubKeysJson, issuer, err := l.fetchJWKSandIssuer()
 	pubKeysJson, issuer, err := l.fetchJWKSandIssuer()
 	if err != nil {
 	if err != nil {
@@ -303,6 +675,50 @@ func genCertificates(namespace, serviceName string) ([]byte, []byte, []byte, []b
 	return rootPem, rootKeyPEM, serverPem, serverKeyPem, err
 	return rootPem, rootKeyPEM, serverPem, serverKeyPem, err
 }
 }
 
 
+// genSpiffeCert generates a client certificate carrying the given SPIFFE ID as a URI SAN,
+// signed by signingCert/signingKey. This is used by the authn-cert authenticator's 'spiffe'
+// host mode, which derives the authenticated host's identity from the certificate's URI SAN
+// rather than from a role identifier in the request path.
+// genClientAuthCert generates a client certificate for mutual TLS (authn-cert). cn sets the
+// Common Name (used by 'request' host mode's cn restriction annotation); spiffeID, if
+// non-empty, is embedded as a URI SAN (used by 'spiffe' host mode).
+func genClientAuthCert(signingCert *x509.Certificate, signingKey *rsa.PrivateKey, cn, spiffeID string) ([]byte, *rsa.PrivateKey, error) {
+	var uris []*url.URL
+	if spiffeID != "" {
+		uri, err := url.Parse(spiffeID)
+		if err != nil {
+			return nil, nil, fmt.Errorf("unable to parse spiffe id: %w", err)
+		}
+		uris = []*url.URL{uri}
+	}
+
+	pkey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, nil, err
+	}
+	tpl := x509.Certificate{
+		Subject: pkix.Name{
+			Country:      []string{"/dev/null"},
+			Organization: []string{"External Secrets ACME"},
+			CommonName:   cn,
+		},
+		SerialNumber: big.NewInt(1),
+		NotBefore:    time.Now(),
+		NotAfter:     time.Now().Add(time.Hour),
+		// DigitalSignature is required for the client to sign the TLS handshake during
+		// mutual TLS; CRLSign (used by the sibling genPeerCert for server certs) does not
+		// cover that purpose and nginx's strict (critical) key usage check rejects the
+		// handshake outright if it's missing.
+		KeyUsage:       x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:    []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+		IsCA:           false,
+		MaxPathLenZero: true,
+		URIs:           uris,
+	}
+	_, certPEM, err := genCert(&tpl, signingCert, &pkey.PublicKey, signingKey)
+	return certPEM, pkey, err
+}
+
 func (l *Conjur) Logs() error {
 func (l *Conjur) Logs() error {
 	return l.chart.Logs()
 	return l.chart.Logs()
 }
 }

+ 1 - 1
e2e/go.mod

@@ -52,7 +52,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13
 	github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.9
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.9
 	github.com/aws/aws-sdk-go-v2/service/ssm v1.66.3
 	github.com/aws/aws-sdk-go-v2/service/ssm v1.66.3
-	github.com/cyberark/conjur-api-go v0.13.8
+	github.com/cyberark/conjur-api-go v0.14.1
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/providers/v1/azure v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/providers/v1/azure v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/providers/v1/gcp v0.0.0-00010101000000-000000000000
 	github.com/external-secrets/external-secrets/providers/v1/gcp v0.0.0-00010101000000-000000000000

+ 2 - 2
e2e/go.sum

@@ -129,8 +129,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
 github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
 github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
 github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
-github.com/cyberark/conjur-api-go v0.13.8 h1:woBEg+lvcoghpFkoGcS2JfPy+kJR59Ir7LeZcNA9wWE=
-github.com/cyberark/conjur-api-go v0.13.8/go.mod h1:xGi4RCulvsc+x/jYRrxUoEShznhlKP/4hJC/4+lueFg=
+github.com/cyberark/conjur-api-go v0.14.1 h1:6rF5smYVJ5wV/SpnJkVGQSYfYFNj4ltaWQXsfL1K2+c=
+github.com/cyberark/conjur-api-go v0.14.1/go.mod h1:POjKgJwnniONTnWySx6Yq87hYsdPD6Ohwm7x1UGio7E=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

+ 1 - 1
e2e/k8s/conjur.values.yaml

@@ -1,4 +1,4 @@
-authenticators: authn,authn-jwt/eso-tests,authn-jwt/eso-tests-hostid
+authenticators: authn,authn-jwt/eso-tests,authn-jwt/eso-tests-hostid,authn-cert/eso-tests,authn-cert/eso-tests-hostid
 logLevel: "debug"
 logLevel: "debug"
 service:
 service:
   external:
   external:

+ 34 - 0
e2e/suites/provider/cases/conjur/conjur.go

@@ -28,6 +28,8 @@ const (
 	withTokenAuth    = "with apikey auth"
 	withTokenAuth    = "with apikey auth"
 	withJWTK8s       = "with jwt k8s provider"
 	withJWTK8s       = "with jwt k8s provider"
 	withJWTK8sHostID = "with jwt k8s hostid provider"
 	withJWTK8sHostID = "with jwt k8s hostid provider"
+	withCert         = "with cert provider"
+	withCertHostID   = "with cert hostid provider"
 )
 )
 
 
 var _ = Describe("[conjur]", Label("conjur"), Ordered, func() {
 var _ = Describe("[conjur]", Label("conjur"), Ordered, func() {
@@ -78,6 +80,24 @@ var _ = Describe("[conjur]", Label("conjur"), Ordered, func() {
 		framework.Compose(withJWTK8sHostID, f, common.SyncWithoutTargetName, useJWTK8sHostIDProvider(prov)),
 		framework.Compose(withJWTK8sHostID, f, common.SyncWithoutTargetName, useJWTK8sHostIDProvider(prov)),
 		framework.Compose(withJWTK8sHostID, f, common.JSONDataFromSync, useJWTK8sHostIDProvider(prov)),
 		framework.Compose(withJWTK8sHostID, f, common.JSONDataFromSync, useJWTK8sHostIDProvider(prov)),
 		framework.Compose(withJWTK8sHostID, f, common.JSONDataFromRewrite, useJWTK8sHostIDProvider(prov)),
 		framework.Compose(withJWTK8sHostID, f, common.JSONDataFromRewrite, useJWTK8sHostIDProvider(prov)),
+
+		// use cert provider
+		framework.Compose(withCert, f, common.FindByName, useCertProvider(prov)),
+		framework.Compose(withCert, f, common.FindByNameAndRewrite, useCertProvider(prov)),
+		framework.Compose(withCert, f, common.FindByTag, useCertProvider(prov)),
+		framework.Compose(withCert, f, common.SimpleDataSync, useCertProvider(prov)),
+		framework.Compose(withCert, f, common.SyncWithoutTargetName, useCertProvider(prov)),
+		framework.Compose(withCert, f, common.JSONDataFromSync, useCertProvider(prov)),
+		framework.Compose(withCert, f, common.JSONDataFromRewrite, useCertProvider(prov)),
+
+		// use cert hostid provider
+		framework.Compose(withCertHostID, f, common.FindByName, useCertHostIDProvider(prov)),
+		framework.Compose(withCertHostID, f, common.FindByNameAndRewrite, useCertHostIDProvider(prov)),
+		framework.Compose(withCertHostID, f, common.FindByTag, useCertHostIDProvider(prov)),
+		framework.Compose(withCertHostID, f, common.SimpleDataSync, useCertHostIDProvider(prov)),
+		framework.Compose(withCertHostID, f, common.SyncWithoutTargetName, useCertHostIDProvider(prov)),
+		framework.Compose(withCertHostID, f, common.JSONDataFromSync, useCertHostIDProvider(prov)),
+		framework.Compose(withCertHostID, f, common.JSONDataFromRewrite, useCertHostIDProvider(prov)),
 	)
 	)
 })
 })
 
 
@@ -101,3 +121,17 @@ func useJWTK8sHostIDProvider(prov *conjurProvider) func(tc *framework.TestCase)
 		tc.ExternalSecret.Spec.SecretStoreRef.Name = jwtK8sHostIDProviderName
 		tc.ExternalSecret.Spec.SecretStoreRef.Name = jwtK8sHostIDProviderName
 	}
 	}
 }
 }
+
+func useCertProvider(prov *conjurProvider) func(tc *framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		prov.CreateCertStore()
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = certProviderName
+	}
+}
+
+func useCertHostIDProvider(prov *conjurProvider) func(tc *framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		prov.CreateCertHostIDStore()
+		tc.ExternalSecret.Spec.SecretStoreRef.Name = certHostIDProviderName
+	}
+}

+ 60 - 3
e2e/suites/provider/cases/conjur/policy.go

@@ -17,7 +17,10 @@ package conjur
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"fmt"
 	"text/template"
 	"text/template"
+
+	"github.com/external-secrets/external-secrets-e2e/framework/addon"
 )
 )
 
 
 const createVariablePolicyTemplate = `- !variable
 const createVariablePolicyTemplate = `- !variable
@@ -37,6 +40,16 @@ const createVariablePolicyTemplate = `- !variable
 - !permit
 - !permit
   role: !host system:serviceaccount:{{ .Namespace }}:test-app-hostid-sa
   role: !host system:serviceaccount:{{ .Namespace }}:test-app-hostid-sa
   privilege: [ read, execute ]
   privilege: [ read, execute ]
+  resource: !variable {{ .Key }}
+
+- !permit
+  role: !host vm-01
+  privilege: [ read, execute ]
+  resource: !variable {{ .Key }}
+
+- !permit
+  role: !host {{ .SpiffeHostID }}
+  privilege: [ read, execute ]
   resource: !variable {{ .Key }}`
   resource: !variable {{ .Key }}`
 
 
 const deleteVariablePolicyTemplate = `- !delete
 const deleteVariablePolicyTemplate = `- !delete
@@ -52,11 +65,41 @@ const jwtHostPolicyTemplate = `- !host
   privilege: [ read, authenticate ]
   privilege: [ read, authenticate ]
   resource: !webservice conjur/authn-jwt/{{ .ServiceID }}`
   resource: !webservice conjur/authn-jwt/{{ .ServiceID }}`
 
 
+// certHostIDPolicyTemplate creates the host authenticated by CreateCertHostIDStore, which
+// supplies an explicit HostID in the authentication request (request mode). Request mode
+// requires at least one certificate-matching restriction annotation; 'cn' is used here to
+// match the client certificate's Common Name.
+const certHostIDPolicyTemplate = `- !host
+  id: vm-01
+  annotations:
+    authn-cert/{{ .ServiceID }}/cn: "vm-01"
+
+- !permit
+  role: !host vm-01
+  privilege: [ read, authenticate ]
+  resource: !webservice conjur/authn-cert/{{ .ServiceID }}`
+
+// certSpiffePolicyTemplate creates the host authenticated by CreateCertStore, which omits
+// HostID so the authn-cert authenticator derives the role from the SPIFFE URI SAN in the
+// client certificate (spiffe mode). The host must exist at IdentityPath/WorkloadID to match
+// the identity the authenticator derives from the certificate; no restriction annotation is
+// required or checked in spiffe mode.
+const certSpiffePolicyTemplate = `- !policy
+  id: {{ .IdentityPath }}
+  body:
+    - !host {{ .WorkloadID }}
+
+- !permit
+  role: !host {{ .IdentityPath }}/{{ .WorkloadID }}
+  privilege: [ read, authenticate ]
+  resource: !webservice conjur/authn-cert/{{ .ServiceID }}`
+
 func createVariablePolicy(key, namespace string, tags map[string]string) string {
 func createVariablePolicy(key, namespace string, tags map[string]string) string {
 	return renderTemplate(createVariablePolicyTemplate, map[string]interface{}{
 	return renderTemplate(createVariablePolicyTemplate, map[string]interface{}{
-		"Key":       key,
-		"Namespace": namespace,
-		"Tags":      tags,
+		"Key":          key,
+		"Namespace":    namespace,
+		"Tags":         tags,
+		"SpiffeHostID": fmt.Sprintf("%s/%s", addon.SpiffeIdentityPath, addon.SpiffeWorkloadID),
 	})
 	})
 }
 }
 
 
@@ -73,6 +116,20 @@ func createJwtHostPolicy(hostID, serviceID string) string {
 	})
 	})
 }
 }
 
 
+func createCertHostIDPolicy(serviceID string) string {
+	return renderTemplate(certHostIDPolicyTemplate, map[string]interface{}{
+		"ServiceID": serviceID,
+	})
+}
+
+func createCertSpiffePolicy(serviceID, identityPath, workloadID string) string {
+	return renderTemplate(certSpiffePolicyTemplate, map[string]interface{}{
+		"ServiceID":    serviceID,
+		"IdentityPath": identityPath,
+		"WorkloadID":   workloadID,
+	})
+}
+
 func renderTemplate(templateText string, data map[string]interface{}) string {
 func renderTemplate(templateText string, data map[string]interface{}) string {
 	// Use golang templates to render the policy
 	// Use golang templates to render the policy
 	tmpl, err := template.New("policy").Parse(templateText)
 	tmpl, err := template.New("policy").Parse(templateText)

+ 89 - 0
e2e/suites/provider/cases/conjur/provider.go

@@ -44,6 +44,10 @@ const (
 	secretName               = "conjur-creds"
 	secretName               = "conjur-creds"
 	jwtK8sProviderName       = "jwt-k8s-provider"
 	jwtK8sProviderName       = "jwt-k8s-provider"
 	jwtK8sHostIDProviderName = "jwt-k8s-hostid-provider"
 	jwtK8sHostIDProviderName = "jwt-k8s-hostid-provider"
+	certProviderName         = "cert-provider"
+	certHostIDProviderName   = "cert-hostid-provider"
+	certSecretName           = "conjur-client-cert"
+	certHostIDSecretName     = "conjur-client-cert-hostid"
 	hostidServiceAccountName = "test-app-hostid-sa"
 	hostidServiceAccountName = "test-app-hostid-sa"
 	appServiceAccountName    = "test-app-sa"
 	appServiceAccountName    = "test-app-sa"
 )
 )
@@ -88,7 +92,18 @@ func (s *conjurProvider) BeforeEach() {
 	// setup policy
 	// setup policy
 	saName = "system:serviceaccount:" + s.framework.Namespace.Name + ":" + hostidServiceAccountName
 	saName = "system:serviceaccount:" + s.framework.Namespace.Name + ":" + hostidServiceAccountName
 	policy = createJwtHostPolicy(saName, "eso-tests-hostid")
 	policy = createJwtHostPolicy(saName, "eso-tests-hostid")
+	_, err = s.addon.ConjurClient.LoadPolicy(conjurapi.PolicyModePost, "root", strings.NewReader(policy))
+	Expect(err).ToNot(HaveOccurred())
+
+	// setup policy for the authn-cert authenticator's 'request' host mode
+	// (used by CreateCertHostIDStore, which supplies an explicit HostID)
+	policy = createCertHostIDPolicy("eso-tests-hostid")
+	_, err = s.addon.ConjurClient.LoadPolicy(conjurapi.PolicyModePost, "root", strings.NewReader(policy))
+	Expect(err).ToNot(HaveOccurred())
 
 
+	// setup policy for the authn-cert authenticator's 'spiffe' host mode
+	// (used by CreateCertStore, which omits HostID)
+	policy = createCertSpiffePolicy("eso-tests", addon.SpiffeIdentityPath, addon.SpiffeWorkloadID)
 	_, err = s.addon.ConjurClient.LoadPolicy(conjurapi.PolicyModePost, "root", strings.NewReader(policy))
 	_, err = s.addon.ConjurClient.LoadPolicy(conjurapi.PolicyModePost, "root", strings.NewReader(policy))
 	Expect(err).ToNot(HaveOccurred())
 	Expect(err).ToNot(HaveOccurred())
 }
 }
@@ -204,3 +219,77 @@ func (s conjurProvider) CreateJWTK8sHostIDStore() {
 	err = s.framework.CRClient.Create(GinkgoT().Context(), secretStore)
 	err = s.framework.CRClient.Create(GinkgoT().Context(), secretStore)
 	Expect(err).ToNot(HaveOccurred())
 	Expect(err).ToNot(HaveOccurred())
 }
 }
+
+func (s conjurProvider) CreateCertStore() {
+	// This store omits HostID, so the authn-cert authenticator operates in 'spiffe' host
+	// mode and derives the authenticated host's identity from the SPIFFE URI SAN in the
+	// certificate rather than from a role identifier supplied by the client.
+	By("creating a conjur client cert secret")
+	certSecret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      certSecretName,
+			Namespace: s.framework.Namespace.Name,
+		},
+		Data: map[string][]byte{
+			"clientCert": s.addon.SpiffeCert,
+			"clientKey":  s.addon.SpiffeKey,
+		},
+	}
+	err := s.framework.CRClient.Create(GinkgoT().Context(), certSecret)
+	Expect(err).ToNot(HaveOccurred())
+
+	By("creating a secret store for conjur with cert auth")
+	secretStore := makeStore(certProviderName, s.framework.Namespace.Name, s.addon)
+	secretStore.Spec.Provider.Conjur.Auth = esv1.ConjurAuth{
+		Cert: &esv1.ConjurCert{
+			Account:   "default",
+			ServiceID: "eso-tests",
+			ClientCertRef: &esmeta.SecretKeySelector{
+				Name: certSecretName,
+				Key:  "clientCert",
+			},
+			ClientKeyRef: &esmeta.SecretKeySelector{
+				Name: certSecretName,
+				Key:  "clientKey",
+			},
+		},
+	}
+	err = s.framework.CRClient.Create(GinkgoT().Context(), secretStore)
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (s conjurProvider) CreateCertHostIDStore() {
+	By("creating a conjur client cert secret for hostid")
+	certSecret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      certHostIDSecretName,
+			Namespace: s.framework.Namespace.Name,
+		},
+		Data: map[string][]byte{
+			"clientCert": s.addon.ClientCert,
+			"clientKey":  s.addon.ClientKey,
+		},
+	}
+	err := s.framework.CRClient.Create(GinkgoT().Context(), certSecret)
+	Expect(err).ToNot(HaveOccurred())
+
+	By("creating a secret store for conjur with cert auth and host ID")
+	secretStore := makeStore(certHostIDProviderName, s.framework.Namespace.Name, s.addon)
+	secretStore.Spec.Provider.Conjur.Auth = esv1.ConjurAuth{
+		Cert: &esv1.ConjurCert{
+			Account:   "default",
+			HostID:    "vm-01",
+			ServiceID: "eso-tests-hostid",
+			ClientCertRef: &esmeta.SecretKeySelector{
+				Name: certHostIDSecretName,
+				Key:  "clientCert",
+			},
+			ClientKeyRef: &esmeta.SecretKeySelector{
+				Name: certHostIDSecretName,
+				Key:  "clientKey",
+			},
+		},
+	}
+	err = s.framework.CRClient.Create(GinkgoT().Context(), secretStore)
+	Expect(err).ToNot(HaveOccurred())
+}

+ 1 - 1
go.mod

@@ -238,7 +238,7 @@ require (
 	github.com/cloudflare/circl v1.6.3 // indirect
 	github.com/cloudflare/circl v1.6.3 // indirect
 	github.com/cloudru-tech/iam-sdk v1.0.4 // indirect
 	github.com/cloudru-tech/iam-sdk v1.0.4 // indirect
 	github.com/cloudru-tech/secret-manager-sdk v1.1.1 // indirect
 	github.com/cloudru-tech/secret-manager-sdk v1.1.1 // indirect
-	github.com/cyberark/conjur-api-go v0.13.8 // indirect
+	github.com/cyberark/conjur-api-go v0.14.1 // indirect
 	github.com/danieljoos/wincred v1.2.3 // indirect
 	github.com/danieljoos/wincred v1.2.3 // indirect
 	github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
 	github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
 	github.com/extism/go-sdk v1.7.1 // indirect
 	github.com/extism/go-sdk v1.7.1 // indirect

+ 2 - 2
go.sum

@@ -289,8 +289,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/ctdk/goiardi v0.11.10 h1:IB/3Afl1pC2Q4KGwzmhHPAoJfe8VtU51wZ2V0QkvsL0=
 github.com/ctdk/goiardi v0.11.10 h1:IB/3Afl1pC2Q4KGwzmhHPAoJfe8VtU51wZ2V0QkvsL0=
 github.com/ctdk/goiardi v0.11.10/go.mod h1:Pr6Cj6Wsahw45myttaOEZeZ0LE7p1qzWmzgsBISkrNI=
 github.com/ctdk/goiardi v0.11.10/go.mod h1:Pr6Cj6Wsahw45myttaOEZeZ0LE7p1qzWmzgsBISkrNI=
-github.com/cyberark/conjur-api-go v0.13.8 h1:woBEg+lvcoghpFkoGcS2JfPy+kJR59Ir7LeZcNA9wWE=
-github.com/cyberark/conjur-api-go v0.13.8/go.mod h1:xGi4RCulvsc+x/jYRrxUoEShznhlKP/4hJC/4+lueFg=
+github.com/cyberark/conjur-api-go v0.14.1 h1:6rF5smYVJ5wV/SpnJkVGQSYfYFNj4ltaWQXsfL1K2+c=
+github.com/cyberark/conjur-api-go v0.14.1/go.mod h1:POjKgJwnniONTnWySx6Yq87hYsdPD6Ohwm7x1UGio7E=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

+ 42 - 0
providers/v1/conjur/client.go

@@ -37,6 +37,8 @@ var (
 	errConjurClient          = "cannot setup new Conjur client: %w"
 	errConjurClient          = "cannot setup new Conjur client: %w"
 	errBadServiceUser        = "could not get Auth.Apikey.UserRef: %w"
 	errBadServiceUser        = "could not get Auth.Apikey.UserRef: %w"
 	errBadServiceAPIKey      = "could not get Auth.Apikey.ApiKeyRef: %w"
 	errBadServiceAPIKey      = "could not get Auth.Apikey.ApiKeyRef: %w"
+	errBadClientCert         = "could not get Auth.Cert.ClientCertRef: %w"
+	errBadClientKey          = "could not get Auth.Cert.ClientKeyRef: %w"
 	errGetKubeSATokenRequest = "cannot request Kubernetes service account token for service account %q: %w"
 	errGetKubeSATokenRequest = "cannot request Kubernetes service account token for service account %q: %w"
 	errSecretKeyFmt          = "cannot find secret data for key: %q"
 	errSecretKeyFmt          = "cannot find secret data for key: %q"
 )
 )
@@ -91,6 +93,10 @@ func (c *Client) GetConjurClient(ctx context.Context) (SecretsClient, error) {
 	if prov.Auth.Jwt != nil {
 	if prov.Auth.Jwt != nil {
 		return c.conjurClientFromJWT(ctx, config, prov)
 		return c.conjurClientFromJWT(ctx, config, prov)
 	}
 	}
+	if prov.Auth.Cert != nil {
+		return c.conjurClientFromCert(ctx, config, prov)
+	}
+
 	// Should not happen because validate func should catch this
 	// Should not happen because validate func should catch this
 	return nil, errors.New("no authentication method provided")
 	return nil, errors.New("no authentication method provided")
 }
 }
@@ -176,3 +182,39 @@ func (c *Client) conjurClientFromJWT(ctx context.Context, config conjurapi.Confi
 	c.client = conjur
 	c.client = conjur
 	return conjur, nil
 	return conjur, nil
 }
 }
+
+func (c *Client) conjurClientFromCert(ctx context.Context, config conjurapi.Config, prov *esv1.ConjurProvider) (SecretsClient, error) {
+	config.AuthnType = "cert"
+	config.Account = prov.Auth.Cert.Account
+	config.ServiceID = prov.Auth.Cert.ServiceID
+	config.CertHostID = prov.Auth.Cert.HostID
+
+	clientCert, secErr := resolvers.SecretKeyRef(
+		ctx,
+		c.kube,
+		c.StoreKind,
+		c.namespace, prov.Auth.Cert.ClientCertRef)
+	if secErr != nil {
+		return nil, fmt.Errorf(errBadClientCert, secErr)
+	}
+	config.ClientCert = clientCert
+
+	clientKey, secErr := resolvers.SecretKeyRef(
+		ctx,
+		c.kube,
+		c.StoreKind,
+		c.namespace,
+		prov.Auth.Cert.ClientKeyRef)
+	if secErr != nil {
+		return nil, fmt.Errorf(errBadClientKey, secErr)
+	}
+	config.ClientCertKey = clientKey
+
+	conjur, clientError := c.clientAPI.NewClientFromCert(config)
+	if clientError != nil {
+		return nil, fmt.Errorf(errConjurClient, clientError)
+	}
+
+	c.client = conjur
+	return conjur, nil
+}

+ 6 - 0
providers/v1/conjur/conjur_api.go

@@ -32,6 +32,7 @@ type SecretsClient interface {
 type SecretsClientFactory interface {
 type SecretsClientFactory interface {
 	NewClientFromKey(config conjurapi.Config, loginPair authn.LoginPair) (SecretsClient, error)
 	NewClientFromKey(config conjurapi.Config, loginPair authn.LoginPair) (SecretsClient, error)
 	NewClientFromJWT(config conjurapi.Config) (SecretsClient, error)
 	NewClientFromJWT(config conjurapi.Config) (SecretsClient, error)
+	NewClientFromCert(config conjurapi.Config) (SecretsClient, error)
 }
 }
 
 
 // ClientAPIImpl is an implementation of the ClientAPI interface.
 // ClientAPIImpl is an implementation of the ClientAPI interface.
@@ -46,3 +47,8 @@ func (c *ClientAPIImpl) NewClientFromKey(config conjurapi.Config, loginPair auth
 func (c *ClientAPIImpl) NewClientFromJWT(config conjurapi.Config) (SecretsClient, error) {
 func (c *ClientAPIImpl) NewClientFromJWT(config conjurapi.Config) (SecretsClient, error) {
 	return conjurapi.NewClientFromJwt(config)
 	return conjurapi.NewClientFromJwt(config)
 }
 }
+
+// NewClientFromCert creates a new Conjur client using certificate-based authentication.
+func (c *ClientAPIImpl) NewClientFromCert(config conjurapi.Config) (SecretsClient, error) {
+	return conjurapi.NewClientFromCertificate(config)
+}

+ 1 - 2
providers/v1/conjur/go.mod

@@ -3,7 +3,7 @@ module github.com/external-secrets/external-secrets/providers/v1/conjur
 go 1.26.5
 go 1.26.5
 
 
 require (
 require (
-	github.com/cyberark/conjur-api-go v0.13.8
+	github.com/cyberark/conjur-api-go v0.14.1
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/runtime v0.0.0
 	github.com/external-secrets/external-secrets/runtime v0.0.0
 	github.com/golang-jwt/jwt/v5 v5.3.0
 	github.com/golang-jwt/jwt/v5 v5.3.0
@@ -104,7 +104,6 @@ require (
 	google.golang.org/protobuf v1.36.11 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
-	gopkg.in/yaml.v2 v2.4.0 // indirect
 	k8s.io/apiextensions-apiserver v0.35.2 // indirect
 	k8s.io/apiextensions-apiserver v0.35.2 // indirect
 	k8s.io/klog/v2 v2.140.0 // indirect
 	k8s.io/klog/v2 v2.140.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf // indirect
 	k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf // indirect

+ 2 - 4
providers/v1/conjur/go.sum

@@ -38,8 +38,8 @@ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1U
 github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
 github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cyberark/conjur-api-go v0.13.8 h1:woBEg+lvcoghpFkoGcS2JfPy+kJR59Ir7LeZcNA9wWE=
-github.com/cyberark/conjur-api-go v0.13.8/go.mod h1:xGi4RCulvsc+x/jYRrxUoEShznhlKP/4hJC/4+lueFg=
+github.com/cyberark/conjur-api-go v0.14.1 h1:6rF5smYVJ5wV/SpnJkVGQSYfYFNj4ltaWQXsfL1K2+c=
+github.com/cyberark/conjur-api-go v0.14.1/go.mod h1:POjKgJwnniONTnWySx6Yq87hYsdPD6Ohwm7x1UGio7E=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -257,8 +257,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf
 gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
 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 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 233 - 8
providers/v1/conjur/provider_test.go

@@ -41,13 +41,16 @@ import (
 )
 )
 
 
 var (
 var (
-	svcURL           = "https://example.com"
-	svcUser          = "user"
-	svcApikey        = "apikey"
-	svcAccount       = "account1"
-	jwtAuthenticator = "jwt-authenticator"
-	jwtAuthnService  = "jwt-auth-service"
-	jwtSecretName    = "jwt-secret"
+	svcURL             = "https://example.com"
+	svcUser            = "user"
+	svcApikey          = "apikey"
+	svcAccount         = "account1"
+	jwtAuthenticator   = "jwt-authenticator"
+	jwtAuthnService    = "jwt-auth-service"
+	jwtSecretName      = "jwt-secret"
+	certServiceID      = "cert-auth-service"
+	certClientCertName = "conjur-client-cert"
+	certClientKeyName  = "conjur-client-key"
 )
 )
 
 
 func makeValidRef(k string) *esv1.ExternalSecretDataRemoteRef {
 func makeValidRef(k string) *esv1.ExternalSecretDataRemoteRef {
@@ -186,6 +189,92 @@ func TestGetSecret(t *testing.T) {
 				value: "secret",
 				value: "secret",
 			},
 			},
 		},
 		},
+		"CertReadSecretSuccess": {
+			reason: "Should read a secret successfully using a Cert auth secret store (spiffe).",
+			args: args{
+				store: makeCertSecretStore(svcURL, certServiceID, "", "myconjuraccount"),
+				kube: clientfake.NewClientBuilder().
+					WithObjects(makeFakeCertSecrets()...).Build(),
+				namespace:  "default",
+				secretPath: "path/to/secret",
+			},
+			want: want{
+				err:   nil,
+				value: "secret",
+			},
+		},
+		"CertWithHostIdReadSecretSuccess": {
+			reason: "Should read a secret successfully using a Cert auth secret store with a host ID.",
+			args: args{
+				store: makeCertSecretStore(svcURL, certServiceID, "myhostid", "myconjuraccount"),
+				kube: clientfake.NewClientBuilder().
+					WithObjects(makeFakeCertSecrets()...).Build(),
+				namespace:  "default",
+				secretPath: "path/to/secret",
+			},
+			want: want{
+				err:   nil,
+				value: "secret",
+			},
+		},
+		"CertReadSecretFailure": {
+			reason: "Should fail to read secret using Cert auth secret store.",
+			args: args{
+				store: makeCertSecretStore(svcURL, certServiceID, "", "myconjuraccount"),
+				kube: clientfake.NewClientBuilder().
+					WithObjects(makeFakeCertSecrets()...).Build(),
+				namespace:  "default",
+				secretPath: "error",
+			},
+			want: want{
+				err:   errors.New("error"),
+				value: "",
+			},
+		},
+		"CertMissingClientCertSecret": {
+			reason: "Should fail when the client certificate secret does not exist.",
+			args: args{
+				store: makeCertSecretStore(svcURL, certServiceID, "", "myconjuraccount"),
+				kube: clientfake.NewClientBuilder().
+					WithObjects(&corev1.Secret{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      certClientKeyName,
+							Namespace: "default",
+						},
+						Data: map[string][]byte{
+							"tls.key": []byte("-----BEGIN RSA PRIVATE KEY-----\nfakekey\n-----END RSA PRIVATE KEY-----"),
+						},
+					}).Build(),
+				namespace:  "default",
+				secretPath: "path/to/secret",
+			},
+			want: want{
+				err:   fmt.Errorf(errBadClientCert, fmt.Errorf("cannot get Kubernetes secret \"%s\" from namespace \"default\": secrets \"%s\" not found", certClientCertName, certClientCertName)),
+				value: "",
+			},
+		},
+		"CertMissingClientKeySecret": {
+			reason: "Should fail when the client key secret does not exist.",
+			args: args{
+				store: makeCertSecretStore(svcURL, certServiceID, "", "myconjuraccount"),
+				kube: clientfake.NewClientBuilder().
+					WithObjects(&corev1.Secret{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      certClientCertName,
+							Namespace: "default",
+						},
+						Data: map[string][]byte{
+							"tls.crt": []byte("-----BEGIN CERTIFICATE-----\nfakecert\n-----END CERTIFICATE-----"),
+						},
+					}).Build(),
+				namespace:  "default",
+				secretPath: "path/to/secret",
+			},
+			want: want{
+				err:   fmt.Errorf(errBadClientKey, fmt.Errorf("cannot get Kubernetes secret \"%s\" from namespace \"default\": secrets \"%s\" not found", certClientKeyName, certClientKeyName)),
+				value: "",
+			},
+		},
 	}
 	}
 
 
 	runTest := func(t *testing.T, _ string, tc testCase) {
 	runTest := func(t *testing.T, _ string, tc testCase) {
@@ -630,12 +719,121 @@ func makeStoreWithCA(caSource, caData string) *esv1.SecretStore {
 	return store
 	return store
 }
 }
 
 
-func makeNoAuthSecretStore(svcURL string) *esv1.SecretStore {
+func makeCertSecretStore(svcURL, certServiceID, hostID, conjurAccount string) *esv1.SecretStore {
 	store := &esv1.SecretStore{
 	store := &esv1.SecretStore{
 		Spec: esv1.SecretStoreSpec{
 		Spec: esv1.SecretStoreSpec{
 			Provider: &esv1.SecretStoreProvider{
 			Provider: &esv1.SecretStoreProvider{
 				Conjur: &esv1.ConjurProvider{
 				Conjur: &esv1.ConjurProvider{
 					URL: svcURL,
 					URL: svcURL,
+					Auth: esv1.ConjurAuth{
+						Cert: &esv1.ConjurCert{
+							Account:   conjurAccount,
+							ServiceID: certServiceID,
+							HostID:    hostID,
+							ClientCertRef: &esmeta.SecretKeySelector{
+								Name: certClientCertName,
+								Key:  "tls.crt",
+							},
+							ClientKeyRef: &esmeta.SecretKeySelector{
+								Name: certClientKeyName,
+								Key:  "tls.key",
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	return store
+}
+
+func makeCertSecretStoreWithMissingRefs(svcURL, certServiceID, conjurAccount string, hasCert, hasKey bool) *esv1.SecretStore {
+	var certRef *esmeta.SecretKeySelector
+	var keyRef *esmeta.SecretKeySelector
+	if hasCert {
+		certRef = &esmeta.SecretKeySelector{
+			Name: certClientCertName,
+			Key:  "tls.crt",
+		}
+	}
+	if hasKey {
+		keyRef = &esmeta.SecretKeySelector{
+			Name: certClientKeyName,
+			Key:  "tls.key",
+		}
+	}
+	store := &esv1.SecretStore{
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Conjur: &esv1.ConjurProvider{
+					URL: svcURL,
+					Auth: esv1.ConjurAuth{
+						Cert: &esv1.ConjurCert{
+							Account:       conjurAccount,
+							ServiceID:     certServiceID,
+							ClientCertRef: certRef,
+							ClientKeyRef:  keyRef,
+						},
+					},
+				},
+			},
+		},
+	}
+	return store
+}
+
+func makeMultiAuthSecretStore(svcURL string) *esv1.SecretStore {
+	return &esv1.SecretStore{
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Conjur: &esv1.ConjurProvider{
+					URL: svcURL,
+					Auth: esv1.ConjurAuth{
+						APIKey: &esv1.ConjurAPIKey{
+							Account:   svcAccount,
+							UserRef:   &esmeta.SecretKeySelector{Name: svcUser, Key: "username"},
+							APIKeyRef: &esmeta.SecretKeySelector{Name: svcApikey, Key: "apikey"},
+						},
+						Jwt: &esv1.ConjurJWT{
+							Account:           "myconjuraccount",
+							ServiceID:         jwtAuthnService,
+							ServiceAccountRef: &esmeta.ServiceAccountSelector{Name: "conjur"},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func makeCertSecretStoreWithEmptyRefNames(svcURL, certServiceID, conjurAccount string, emptyCertName, emptyKeyName bool) *esv1.SecretStore {
+	certName := certClientCertName
+	if emptyCertName {
+		certName = ""
+	}
+	keyName := certClientKeyName
+	if emptyKeyName {
+		keyName = ""
+	}
+	store := &esv1.SecretStore{
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				Conjur: &esv1.ConjurProvider{
+					URL: svcURL,
+					Auth: esv1.ConjurAuth{
+						Cert: &esv1.ConjurCert{
+							Account:   conjurAccount,
+							ServiceID: certServiceID,
+							ClientCertRef: &esmeta.SecretKeySelector{
+								Name: certName,
+								Key:  "tls.crt",
+							},
+							ClientKeyRef: &esmeta.SecretKeySelector{
+								Name: keyName,
+								Key:  "tls.key",
+							},
+						},
+					},
 				},
 				},
 			},
 			},
 		},
 		},
@@ -666,6 +864,29 @@ func makeFakeAPIKeySecrets() []kclient.Object {
 	}
 	}
 }
 }
 
 
+func makeFakeCertSecrets() []kclient.Object {
+	return []kclient.Object{
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      certClientCertName,
+				Namespace: "default",
+			},
+			Data: map[string][]byte{
+				"tls.crt": []byte("-----BEGIN CERTIFICATE-----\nfakecert\n-----END CERTIFICATE-----"),
+			},
+		},
+		&corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      certClientKeyName,
+				Namespace: "default",
+			},
+			Data: map[string][]byte{
+				"tls.key": []byte("-----BEGIN RSA PRIVATE KEY-----\nfakekey\n-----END RSA PRIVATE KEY-----"),
+			},
+		},
+	}
+}
+
 func makeFakeCASource(kind, caData string) kclient.Object {
 func makeFakeCASource(kind, caData string) kclient.Object {
 	if kind == "secret" {
 	if kind == "secret" {
 		return &corev1.Secret{
 		return &corev1.Secret{
@@ -707,6 +928,10 @@ func createFakeJwtToken(expires bool) string {
 type ConjurMockAPIClient struct {
 type ConjurMockAPIClient struct {
 }
 }
 
 
+func (c *ConjurMockAPIClient) NewClientFromCert(_ conjurapi.Config) (SecretsClient, error) {
+	return &fake.ConjurMockClient{}, nil
+}
+
 func (c *ConjurMockAPIClient) NewClientFromKey(_ conjurapi.Config, _ authn.LoginPair) (SecretsClient, error) {
 func (c *ConjurMockAPIClient) NewClientFromKey(_ conjurapi.Config, _ authn.LoginPair) (SecretsClient, error) {
 	return &fake.ConjurMockClient{}, nil
 	return &fake.ConjurMockClient{}, nil
 }
 }

+ 56 - 3
providers/v1/conjur/validate.go

@@ -38,6 +38,11 @@ func (p *Provider) ValidateStore(store esv1.GenericStore) (admission.Warnings, e
 	if prov.URL == "" {
 	if prov.URL == "" {
 		return nil, errors.New("conjur URL cannot be empty")
 		return nil, errors.New("conjur URL cannot be empty")
 	}
 	}
+
+	if err := validateAuthCount(prov.Auth); err != nil {
+		return nil, err
+	}
+
 	if prov.Auth.APIKey != nil {
 	if prov.Auth.APIKey != nil {
 		err := validateAPIKeyStore(store, *prov.Auth.APIKey)
 		err := validateAPIKeyStore(store, *prov.Auth.APIKey)
 		if err != nil {
 		if err != nil {
@@ -52,14 +57,33 @@ func (p *Provider) ValidateStore(store esv1.GenericStore) (admission.Warnings, e
 		}
 		}
 	}
 	}
 
 
-	// At least one auth must be configured
-	if prov.Auth.APIKey == nil && prov.Auth.Jwt == nil {
-		return nil, errors.New("missing Auth.* configuration")
+	if prov.Auth.Cert != nil {
+		err := validateCertStore(store, *prov.Auth.Cert)
+		if err != nil {
+			return nil, err
+		}
 	}
 	}
 
 
 	return nil, nil
 	return nil, nil
 }
 }
 
 
+func validateAuthCount(auth esv1.ConjurAuth) error {
+	count := 0
+	if auth.APIKey != nil {
+		count++
+	}
+	if auth.Jwt != nil {
+		count++
+	}
+	if auth.Cert != nil {
+		count++
+	}
+	if count != 1 {
+		return errors.New("must specify exactly one Auth.* method")
+	}
+	return nil
+}
+
 func validateAPIKeyStore(store esv1.GenericStore, auth esv1.ConjurAPIKey) error {
 func validateAPIKeyStore(store esv1.GenericStore, auth esv1.ConjurAPIKey) error {
 	if auth.Account == "" {
 	if auth.Account == "" {
 		return errors.New("missing Auth.ApiKey.Account")
 		return errors.New("missing Auth.ApiKey.Account")
@@ -101,3 +125,32 @@ func validateJWTStore(store esv1.GenericStore, auth esv1.ConjurJWT) error {
 	}
 	}
 	return nil
 	return nil
 }
 }
+
+func validateCertStore(store esv1.GenericStore, auth esv1.ConjurCert) error {
+	if auth.Account == "" {
+		return errors.New("missing Auth.Cert.Account")
+	}
+	if auth.ServiceID == "" {
+		return errors.New("missing Auth.Cert.ServiceID")
+	}
+	if auth.ClientCertRef == nil {
+		return errors.New("missing Auth.Cert.ClientCertRef")
+	}
+	if auth.ClientCertRef.Name == "" {
+		return errors.New("missing Auth.Cert.ClientCertRef.Name")
+	}
+	if err := esutils.ValidateReferentSecretSelector(store, *auth.ClientCertRef); err != nil {
+		return fmt.Errorf("invalid Auth.Cert.ClientCertRef: %w", err)
+	}
+	if auth.ClientKeyRef == nil {
+		return errors.New("missing Auth.Cert.ClientKeyRef")
+	}
+	if auth.ClientKeyRef.Name == "" {
+		return errors.New("missing Auth.Cert.ClientKeyRef.Name")
+	}
+	if err := esutils.ValidateReferentSecretSelector(store, *auth.ClientKeyRef); err != nil {
+		return fmt.Errorf("invalid Auth.Cert.ClientKeyRef: %w", err)
+	}
+
+	return nil
+}

+ 51 - 2
providers/v1/conjur/validate_test.go

@@ -77,8 +77,57 @@ func TestValidateStore(t *testing.T) {
 		},
 		},
 
 
 		{
 		{
-			store: makeNoAuthSecretStore(svcURL),
-			err:   errors.New("missing Auth.* configuration"),
+			store: makeCertSecretStore(svcURL, certServiceID, "", svcAccount),
+			err:   nil,
+		},
+		{
+			store: makeCertSecretStore(svcURL, certServiceID, "myhostid", svcAccount),
+			err:   nil,
+		},
+		{
+			store: makeCertSecretStore("", certServiceID, "", svcAccount),
+			err:   errors.New("conjur URL cannot be empty"),
+		},
+		{
+			store: makeCertSecretStore(svcURL, "", "", svcAccount),
+			err:   errors.New("missing Auth.Cert.ServiceID"),
+		},
+		{
+			store: makeCertSecretStore(svcURL, certServiceID, "", ""),
+			err:   errors.New("missing Auth.Cert.Account"),
+		},
+		{
+			store: makeCertSecretStoreWithMissingRefs(svcURL, certServiceID, svcAccount, true, false),
+			err:   errors.New("missing Auth.Cert.ClientKeyRef"),
+		},
+		{
+			store: makeCertSecretStoreWithMissingRefs(svcURL, certServiceID, svcAccount, false, true),
+			err:   errors.New("missing Auth.Cert.ClientCertRef"),
+		},
+		{
+			store: makeCertSecretStoreWithEmptyRefNames(svcURL, certServiceID, svcAccount, true, false),
+			err:   errors.New("missing Auth.Cert.ClientCertRef.Name"),
+		},
+		{
+			store: makeCertSecretStoreWithEmptyRefNames(svcURL, certServiceID, svcAccount, false, true),
+			err:   errors.New("missing Auth.Cert.ClientKeyRef.Name"),
+		},
+		{
+			store: &esv1.SecretStore{
+				Spec: esv1.SecretStoreSpec{
+					Provider: &esv1.SecretStoreProvider{
+						Conjur: &esv1.ConjurProvider{
+							URL:  svcURL,
+							Auth: esv1.ConjurAuth{},
+						},
+					},
+				},
+			},
+			err: errors.New("must specify exactly one Auth.* method"),
+		},
+		{
+			store: makeMultiAuthSecretStore(svcURL),
+			err:   errors.New("must specify exactly one Auth.* method"),
 		},
 		},
 	}
 	}
 	p := Provider{}
 	p := Provider{}

+ 12 - 0
tests/__snapshot__/clustersecretstore-v1.yaml

@@ -244,6 +244,18 @@ spec:
             key: string
             key: string
             name: string
             name: string
             namespace: string
             namespace: string
+        cert:
+          account: string
+          clientCertRef:
+            key: string
+            name: string
+            namespace: string
+          clientKeyRef:
+            key: string
+            name: string
+            namespace: string
+          hostId: string
+          serviceID: string
         jwt:
         jwt:
           account: string
           account: string
           hostId: string
           hostId: string

+ 12 - 0
tests/__snapshot__/secretstore-v1.yaml

@@ -244,6 +244,18 @@ spec:
             key: string
             key: string
             name: string
             name: string
             namespace: string
             namespace: string
+        cert:
+          account: string
+          clientCertRef:
+            key: string
+            name: string
+            namespace: string
+          clientKeyRef:
+            key: string
+            name: string
+            namespace: string
+          hostId: string
+          serviceID: string
         jwt:
         jwt:
           account: string
           account: string
           hostId: string
           hostId: string