Kaynağa Gözat

feat(conjur): Conjur PushSecret (#6272)

* Initial PushSecret implementation for Conjur

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Implement SecretKey/RemoteProperty for Conjur PushSecrets

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Check heritage of Conjur secrets

Don't update secret unless it's changed and we manage it

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Add delegation/consumer groups to conjur policy

Use delegation/consumer groups for permissions assignments.

It's best practise that ESO should own the policy branch that it's
writing to. Permissions should be granted (and managed elsewhere) via
delegation/consumers groups.

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Add test for conjur policy generation

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Refactor conjurPolicy to use generated yaml

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Add tests for conjur PushSecret

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Break out checkSecret logic from PushSecret

Break out the logic of whether or not we should try to create/update the
given secret.

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Move "managed-by" annotation into a const

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Wrap errors

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Update comment

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Add explanatory comment for "editable" annotation

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Add test for expectedPolicy for conjur pushsecret

Drive by - indent policy generation tests to match

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

* Return byte array from conjurPolicy

micro-optimisation to avoid string conversion

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>

---------

Signed-off-by: Adrian Lai <adrian.lai@jetstack.io>
Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Adrian Lai 3 hafta önce
ebeveyn
işleme
b903fb6b5c

+ 1 - 0
go.mod

@@ -239,6 +239,7 @@ require (
 	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.14.1 // 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/doodlesbykumbi/conjur-policy-go v0.0.0-20241022093904-5fc5921aee96 // 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
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect

+ 2 - 0
go.sum

@@ -307,6 +307,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
 github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
 github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/doodlesbykumbi/conjur-policy-go v0.0.0-20241022093904-5fc5921aee96 h1:AkFmClMkf4kOmsgSO9UD+PAZINmON40m44PNucHuDlo=
+github.com/doodlesbykumbi/conjur-policy-go v0.0.0-20241022093904-5fc5921aee96/go.mod h1:3M6KY0++RSAgFkJlFYAkl2/kLdwT7j2Pr4L33dMAtrw=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
 github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
 github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
 github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=

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

@@ -23,7 +23,6 @@ import (
 
 
 	"github.com/cyberark/conjur-api-go/conjurapi"
 	"github.com/cyberark/conjur-api-go/conjurapi"
 	"github.com/cyberark/conjur-api-go/conjurapi/authn"
 	"github.com/cyberark/conjur-api-go/conjurapi/authn"
-	corev1 "k8s.io/api/core/v1"
 	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 
@@ -101,11 +100,6 @@ func (c *Client) GetConjurClient(ctx context.Context) (SecretsClient, error) {
 	return nil, errors.New("no authentication method provided")
 	return nil, errors.New("no authentication method provided")
 }
 }
 
 
-// PushSecret will write a single secret into the provider.
-func (c *Client) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
-	return errors.New("pushing secrets is not implemented for the Conjur provider")
-}
-
 // DeleteSecret removes a secret from the provider.
 // DeleteSecret removes a secret from the provider.
 func (c *Client) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
 func (c *Client) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
 	return errors.New("deleting secrets is not implemented for the Conjur provider")
 	return errors.New("deleting secrets is not implemented for the Conjur provider")

+ 222 - 0
providers/v1/conjur/client_push.go

@@ -0,0 +1,222 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package conjur
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/cyberark/conjur-api-go/conjurapi"
+	"github.com/doodlesbykumbi/conjur-policy-go/pkg/conjurpolicy"
+	"gopkg.in/yaml.v3"
+	corev1 "k8s.io/api/core/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/runtime/esutils"
+)
+
+const (
+	// AnnotationManagedByKey is the key for the annotation used to denote that a conjur resource is managed by external-secrets.
+	AnnotationManagedByKey = "managed-by"
+	// AnnotationManagedByValue is the value for the annotation used to denote that a conjur resource is managed by external-secrets.
+	AnnotationManagedByValue = "external-secrets"
+)
+
+func conjurPolicy(name string, vars []string) ([]byte, error) {
+	pvars := make([]conjurpolicy.Resource, len(vars))
+	permits := make([]conjurpolicy.Resource, len(vars))
+
+	for i, v := range vars {
+		pvars[i] = conjurpolicy.Variable{
+			Id: v,
+			Annotations: map[string]any{
+				AnnotationManagedByKey: AnnotationManagedByValue,
+			},
+		}
+		permits[i] = conjurpolicy.Permit{
+			Resources:  conjurpolicy.VariableRef(v),
+			Role:       conjurpolicy.GroupRef("delegation/consumers"),
+			Privileges: []conjurpolicy.Privilege{conjurpolicy.PrivilegeRead, conjurpolicy.PrivilegeExecute},
+		}
+	}
+	p := conjurpolicy.Policy{
+		Id: name,
+		Body: []conjurpolicy.Resource{
+			conjurpolicy.Group{
+				Id: "delegation/consumers",
+				Annotations: map[string]any{
+					AnnotationManagedByKey: AnnotationManagedByValue,
+					// Allow authorized users to manage the members of this group
+					// https://docs.cyberark.com/secrets-manager-sh/12.7/en/content/operations/policy/annotations-conjur.htm#Editableannotationongroupsandlayers
+					"editable": "true",
+				},
+			},
+		},
+	}
+	p.Body = append(p.Body, pvars...)
+	p.Body = append(p.Body, permits...)
+
+	policy, err := yaml.Marshal(conjurpolicy.PolicyStatements{p})
+	if err != nil {
+		return nil, err
+	}
+	return policy, nil
+}
+
+// PushSecret writes a single secret into the provider.
+func (c *Client) PushSecret(ctx context.Context, secret *corev1.Secret, ref esv1.PushSecretData) error {
+	conjurClient, getConjurClientError := c.GetConjurClient(ctx)
+	if getConjurClientError != nil {
+		return getConjurClientError
+	}
+
+	values := map[string]string{}
+	vars := []string{}
+
+	key := ref.GetSecretKey()
+	if key == "" {
+		for k, v := range secret.Data {
+			values[k] = string(v)
+			vars = append(vars, k)
+		}
+	} else {
+		value, ok := secret.Data[key]
+		if !ok {
+			return fmt.Errorf("key %q not found in source secret", key)
+		}
+		values[key] = string(value)
+		vars = append(vars, key)
+	}
+
+	fqSecretName := ref.GetRemoteKey()
+	// if property is empty, we should create multiple variables for each key of the secret
+	property := ref.GetProperty()
+	i := strings.LastIndex(fqSecretName, "/")
+	if i == -1 {
+		return fmt.Errorf("expected RemoteKey (%q) to contain a '/'", fqSecretName)
+	}
+	if property != "" {
+		vars = []string{property}
+	}
+	parentPolicy := fqSecretName[0:i]
+	policyName := fqSecretName[i+1:]
+	// Before we apply policy, we should check any existing secrets to make sure that if they exist, they have the "managed-by" annotation
+	// If they don't, we should leave them alone.
+	// Also, any value that hasn't changed should be removed to avoid spurious updates
+	updateVars, err := checkSecrets(conjurClient, fqSecretName, vars, values, property, key)
+	if err != nil {
+		return fmt.Errorf("failed to check remote secrets: %w", err)
+	}
+
+	// Nothing to update
+	if len(updateVars) == 0 {
+		return nil
+	}
+	policy, err := conjurPolicy(policyName, updateVars)
+	if err != nil {
+		return fmt.Errorf("failed to generate policy: %w", err)
+	}
+
+	_, err = conjurClient.LoadPolicy(conjurapi.PolicyModePost, parentPolicy, bytes.NewReader(policy))
+	if err != nil {
+		return fmt.Errorf("failed to load policy: %w", err)
+	}
+	// if we're not given a property, store all the secrets under the k8s secret key
+	if property == "" {
+		for _, k := range updateVars {
+			err = conjurClient.AddSecret(fmt.Sprintf("%s/%s", fqSecretName, k), values[k])
+			if err != nil {
+				return fmt.Errorf("failed to store secret: %w", err)
+			}
+		}
+	}
+	// if we have a property and a single k8s secret key, store it "as is"
+	if property != "" && key != "" {
+		err = conjurClient.AddSecret(fmt.Sprintf("%s/%s", fqSecretName, property), values[key])
+		if err != nil {
+			return fmt.Errorf("failed to store secret: %w", err)
+		}
+	} else if property != "" && key == "" {
+		// if we have a property, and all the k8s secret fields, store it as a json obj.
+		value, err := esutils.JSONMarshal(values)
+		if err != nil {
+			return fmt.Errorf("failed to json encode secret: %w", err)
+		}
+		err = conjurClient.AddSecret(fmt.Sprintf("%s/%s", fqSecretName, property), string(value))
+		if err != nil {
+			return fmt.Errorf("failed to store secret: %w", err)
+		}
+	}
+	return nil
+}
+
+// checkSecrets checks if secrets exists, if they are managed by eso, and if the values are different.
+// Returns the set of secrets that we should update/create.
+func checkSecrets(conjurClient SecretsClient, conjurSecretName string, conjurVars []string, secretData map[string]string, property, key string) ([]string, error) {
+	updateVars := []string{}
+	for _, v := range conjurVars {
+		n := fmt.Sprintf("%s/%s", conjurSecretName, v)
+		resp, err := conjurClient.GetStaticSecretDetails(n)
+		if err != nil {
+			// assume doesn't exist, so we should create it
+			// Could also be no permission - but that just looks like not found, and then it should fail when we try to create it
+			updateVars = append(updateVars, v)
+			continue
+		}
+		found := false
+		for ak, av := range resp.Annotations {
+			if ak == AnnotationManagedByKey && av == AnnotationManagedByValue {
+				found = true
+				break
+			}
+		}
+		if found == false {
+			continue
+		}
+		secret, err := conjurClient.RetrieveSecret(n)
+		// if we can't read the secret value, assume it's out of our control, don't update it.
+		if err != nil {
+			continue
+		}
+		secretValue := string(secret)
+		if property != "" {
+			// if property and key are present, just a value check
+			if key != "" {
+				if secretValue == secretData[key] {
+					continue
+				}
+			} else {
+				value, err := esutils.JSONMarshal(secretData)
+				if err != nil {
+					return nil, err
+				}
+				if secretValue == string(value) {
+					continue
+				}
+			}
+		} else {
+			if secretValue == secretData[v] {
+				continue
+			}
+		}
+		updateVars = append(updateVars, v)
+	}
+
+	return updateVars, nil
+}

+ 346 - 0
providers/v1/conjur/client_push_test.go

@@ -0,0 +1,346 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package conjur
+
+import (
+	"context"
+	"testing"
+
+	"github.com/cyberark/conjur-api-go/conjurapi"
+	"github.com/doodlesbykumbi/conjur-policy-go/pkg/conjurpolicy"
+	"github.com/stretchr/testify/assert"
+	"gopkg.in/yaml.v3"
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	"github.com/external-secrets/external-secrets/providers/v1/conjur/fake"
+	"github.com/external-secrets/external-secrets/runtime/esutils"
+)
+
+func TestDefaultPolicy(t *testing.T) {
+	policy, err := conjurPolicy("secret1", []string{"foo", "bar", "baz"})
+	assert.NoError(t, err)
+	expected := `
+- !policy
+  id: secret1
+  body:
+    - !group
+      id: delegation/consumers
+      annotations:
+        managed-by: "external-secrets"
+        editable: "true"
+    - !variable
+      id: foo
+      annotations:
+        managed-by: "external-secrets"
+    - !variable
+      id: bar
+      annotations:
+        managed-by: "external-secrets"
+    - !variable
+      id: baz
+      annotations:
+        managed-by: "external-secrets"
+    - !permit
+      resource: !variable foo
+      role: !group delegation/consumers
+      privileges: [ read, execute ]
+    - !permit
+      resource: !variable bar
+      role: !group delegation/consumers
+      privileges: [ read, execute ]
+    - !permit
+      resource: !variable baz
+      role: !group delegation/consumers
+      privileges: [ read, execute ]`
+
+	// roundtrip the expected output through a unmarshal/marshal to remove any formatting related issues
+	p := conjurpolicy.PolicyStatements{}
+	err = yaml.Unmarshal([]byte(expected), &p)
+	assert.NoError(t, err)
+
+	exp, err := yaml.Marshal(p)
+	assert.NoError(t, err)
+
+	assert.Equal(t, exp, policy)
+}
+
+type RemoteRef struct {
+	RemoteKey string
+	Property  string
+	SecretKey string
+}
+
+func (r RemoteRef) GetRemoteKey() string {
+	return r.RemoteKey
+}
+
+func (r RemoteRef) GetProperty() string {
+	return r.Property
+}
+
+func (r RemoteRef) GetMetadata() *apiextensionsv1.JSON {
+	return nil
+}
+
+func (r RemoteRef) GetSecretKey() string {
+	return r.SecretKey
+}
+
+func TestPushSecret(t *testing.T) {
+	tests := []struct {
+		name           string
+		secretValue    []byte
+		remoteRef      RemoteRef
+		expectedPolicy string
+		expectedVar    string
+		expectedVal    string
+	}{
+		{
+			name:        "Push specified value to property",
+			secretValue: []byte("password123"),
+			remoteRef: RemoteRef{
+				SecretKey: "password",
+				RemoteKey: "data/vault/eso/db",
+				Property:  "password",
+			},
+			expectedVar: "data/vault/eso/db/password",
+			expectedVal: "password123",
+			expectedPolicy: `- !policy
+  id: db
+  body:
+    - !group
+      id: delegation/consumers
+      annotations:
+        editable: "true"
+        managed-by: external-secrets
+    - !variable
+      id: password
+      annotations:
+        managed-by: external-secrets
+    - !permit
+      role: !group delegation/consumers
+      privileges: [read, execute]
+      resource: !variable password
+`,
+		},
+		{
+			name:        "Push all values to a single property",
+			secretValue: []byte("password123"),
+			remoteRef: RemoteRef{
+				RemoteKey: "data/vault/eso/db",
+				Property:  "password",
+			},
+			expectedVar: "data/vault/eso/db/password",
+			expectedVal: `{"password":"password123"}`,
+		},
+		{
+			name:        "Push all values, unspecified property",
+			secretValue: []byte("password123"),
+			remoteRef: RemoteRef{
+				RemoteKey: "data/vault/eso/db",
+			},
+			expectedVar: "data/vault/eso/db/password",
+			expectedVal: "password123",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mockClient := &fake.ConjurMockClient{}
+			provider := &Client{
+				store: makeAPIKeySecretStore(svcURL, "conjur-hostid", "conjur-apikey", "myconjuraccount"),
+				kube: clientfake.NewClientBuilder().
+					WithObjects(makeFakeAPIKeySecrets()...).Build(),
+				namespace: "default",
+				client:    mockClient,
+			}
+
+			kubeSecret := &corev1.Secret{
+				Data: map[string][]byte{
+					"password": tt.secretValue,
+				},
+			}
+
+			err := provider.PushSecret(context.Background(), kubeSecret, tt.remoteRef)
+
+			if err != nil {
+				t.Fatalf("PushSecret failed: %v", err)
+			}
+
+			if len(mockClient.AddSecretCalls) != 1 {
+				t.Errorf("expected 1 AddSecret call, got %d", len(mockClient.AddSecretCalls))
+			} else {
+				call := mockClient.AddSecretCalls[0]
+				if call.Variable != tt.expectedVar {
+					t.Errorf("expected var %s, got %s", tt.expectedVar, call.Variable)
+				}
+				if call.Value != tt.expectedVal {
+					t.Errorf("expected value %s, got %s", tt.expectedVal, call.Value)
+				}
+			}
+
+			if tt.expectedPolicy != "" {
+				if len(mockClient.LoadPolicyCalls) == 0 {
+					t.Error("expected a LoadPolicy call but none occurred")
+				} else {
+					policy := mockClient.LoadPolicyCalls[0].Policy
+					assert.Equal(t, tt.expectedPolicy, policy)
+				}
+			}
+		})
+	}
+}
+
+func TestCheckSecrets(t *testing.T) {
+	tests := []struct {
+		name             string
+		conjurSecretName string
+		conjurVars       []string
+		secretData       map[string]string
+		property         string
+		key              string
+		setupMock        func(m *fake.ConjurMockClient)
+		expectedUpdates  []string
+	}{
+		{
+			name:             "Unmanaged secret (should skip despite drift)",
+			conjurSecretName: "db",
+			conjurVars:       []string{"pass"},
+			secretData:       map[string]string{"pass": "new-value"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretDetails = map[string]*conjurapi.StaticSecretResponse{
+					"db/pass": {
+						StaticSecret: conjurapi.StaticSecret{
+							Annotations: map[string]string{"owner": "manual"},
+						},
+					},
+				}
+				m.SecretValues = map[string][]byte{"db/pass": []byte("old-value")}
+			},
+			expectedUpdates: []string{},
+		},
+		{
+			name:             "Managed secret with drift (should update)",
+			conjurSecretName: "db",
+			conjurVars:       []string{"pass"},
+			secretData:       map[string]string{"pass": "new-value"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"db/pass": []byte("old-value")}
+			},
+			expectedUpdates: []string{"pass"},
+		},
+		{
+			name:             "Managed secret, no drift (should skip)",
+			conjurSecretName: "db",
+			conjurVars:       []string{"pass"},
+			secretData:       map[string]string{"pass": "same-value"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"db/pass": []byte("same-value")}
+			},
+			expectedUpdates: []string{},
+		},
+		{
+			name:             "Property + Key: Value differs (should update)",
+			conjurSecretName: "api",
+			conjurVars:       []string{"token"},
+			property:         "credential",
+			key:              "apikey",
+			secretData:       map[string]string{"apikey": "new-val"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"api/token": []byte("old-val")}
+			},
+			expectedUpdates: []string{"token"},
+		},
+		{
+			name:             "Property + Key: Value matches (should skip)",
+			conjurSecretName: "api",
+			conjurVars:       []string{"token"},
+			property:         "credential",
+			key:              "apikey",
+			secretData:       map[string]string{"apikey": "same-val"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"api/token": []byte("same-val")}
+			},
+			expectedUpdates: []string{},
+		},
+		{
+			name:             "Property only (JSON): Matches marshaled data (should skip)",
+			conjurSecretName: "app",
+			conjurVars:       []string{"config"},
+			property:         "json-blob",
+			key:              "",
+			secretData:       map[string]string{"user": "admin", "port": "8080"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				marshaled, _ := esutils.JSONMarshal(map[string]string{"user": "admin", "port": "8080"})
+				m.SecretValues = map[string][]byte{"app/config": marshaled}
+			},
+			expectedUpdates: []string{},
+		},
+		{
+			name:             "Property only (JSON): Data differs (should update)",
+			conjurSecretName: "app",
+			conjurVars:       []string{"config"},
+			property:         "json-blob",
+			key:              "",
+			secretData:       map[string]string{"user": "admin"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"app/config": []byte(`{"user":"old"}`)}
+			},
+			expectedUpdates: []string{"config"},
+		},
+		{
+			name:             "Key-only (Flat): Value matches (should skip)",
+			conjurSecretName: "db",
+			conjurVars:       []string{"username"},
+			property:         "",
+			secretData:       map[string]string{"username": "admin"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"db/username": []byte("admin")}
+			},
+			expectedUpdates: []string{},
+		},
+		{
+			name:             "Key-only (Flat): Value differs (should update)",
+			conjurSecretName: "db",
+			conjurVars:       []string{"username"},
+			property:         "",
+			secretData:       map[string]string{"username": "new-admin"},
+			setupMock: func(m *fake.ConjurMockClient) {
+				m.SecretValues = map[string][]byte{"db/username": []byte("old-admin")}
+			},
+			expectedUpdates: []string{"username"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			mock := &fake.ConjurMockClient{
+				SecretDetails: make(map[string]*conjurapi.StaticSecretResponse),
+				SecretValues:  make(map[string][]byte),
+			}
+			tt.setupMock(mock)
+
+			updates, err := checkSecrets(mock, tt.conjurSecretName, tt.conjurVars, tt.secretData, tt.property, tt.key)
+
+			assert.NoError(t, err)
+
+			assert.Equal(t, tt.expectedUpdates, updates)
+		})
+	}
+}

+ 35 - 3
providers/v1/conjur/conjur_api.go

@@ -17,12 +17,17 @@ limitations under the License.
 package conjur
 package conjur
 
 
 import (
 import (
+	"io"
+
 	"github.com/cyberark/conjur-api-go/conjurapi"
 	"github.com/cyberark/conjur-api-go/conjurapi"
 	"github.com/cyberark/conjur-api-go/conjurapi/authn"
 	"github.com/cyberark/conjur-api-go/conjurapi/authn"
 )
 )
 
 
 // SecretsClient is an interface for the Conjur client.
 // SecretsClient is an interface for the Conjur client.
 type SecretsClient interface {
 type SecretsClient interface {
+	AddSecret(variable, secret string) error
+	GetStaticSecretDetails(identifier string) (*conjurapi.StaticSecretResponse, error)
+	LoadPolicy(policyMode conjurapi.PolicyMode, policyID string, policy io.Reader) (*conjurapi.PolicyResponse, error)
 	RetrieveSecret(secret string) (result []byte, err error)
 	RetrieveSecret(secret string) (result []byte, err error)
 	RetrieveBatchSecrets(variableIDs []string) (map[string][]byte, error)
 	RetrieveBatchSecrets(variableIDs []string) (map[string][]byte, error)
 	Resources(filter *conjurapi.ResourceFilter) (resources []map[string]any, err error)
 	Resources(filter *conjurapi.ResourceFilter) (resources []map[string]any, err error)
@@ -38,17 +43,44 @@ type SecretsClientFactory interface {
 // ClientAPIImpl is an implementation of the ClientAPI interface.
 // ClientAPIImpl is an implementation of the ClientAPI interface.
 type ClientAPIImpl struct{}
 type ClientAPIImpl struct{}
 
 
+// CompositeClient is the composite of the Client and ClientV2 mechanisms so that API methods from both are accessible.
+type CompositeClient struct {
+	*conjurapi.Client
+	*conjurapi.ClientV2
+}
+
 // NewClientFromKey creates a new Conjur client using API key authentication.
 // NewClientFromKey creates a new Conjur client using API key authentication.
 func (c *ClientAPIImpl) NewClientFromKey(config conjurapi.Config, loginPair authn.LoginPair) (SecretsClient, error) {
 func (c *ClientAPIImpl) NewClientFromKey(config conjurapi.Config, loginPair authn.LoginPair) (SecretsClient, error) {
-	return conjurapi.NewClientFromKey(config, loginPair)
+	client, err := conjurapi.NewClientFromKey(config, loginPair)
+	if err != nil {
+		return nil, err
+	}
+	return CompositeClient{
+		client,
+		&conjurapi.ClientV2{Client: client},
+	}, nil
 }
 }
 
 
 // NewClientFromJWT creates a new Conjur client from a JWT token.
 // NewClientFromJWT creates a new Conjur client from a JWT token.
 func (c *ClientAPIImpl) NewClientFromJWT(config conjurapi.Config) (SecretsClient, error) {
 func (c *ClientAPIImpl) NewClientFromJWT(config conjurapi.Config) (SecretsClient, error) {
-	return conjurapi.NewClientFromJwt(config)
+	client, err := conjurapi.NewClientFromJwt(config)
+	if err != nil {
+		return nil, err
+	}
+	return CompositeClient{
+		client,
+		&conjurapi.ClientV2{Client: client},
+	}, nil
 }
 }
 
 
 // NewClientFromCert creates a new Conjur client using certificate-based authentication.
 // NewClientFromCert creates a new Conjur client using certificate-based authentication.
 func (c *ClientAPIImpl) NewClientFromCert(config conjurapi.Config) (SecretsClient, error) {
 func (c *ClientAPIImpl) NewClientFromCert(config conjurapi.Config) (SecretsClient, error) {
-	return conjurapi.NewClientFromCertificate(config)
+	client, err := conjurapi.NewClientFromCertificate(config)
+	if err != nil {
+		return nil, err
+	}
+	return CompositeClient{
+		client,
+		&conjurapi.ClientV2{Client: client},
+	}, nil
 }
 }

+ 49 - 0
providers/v1/conjur/fake/fake.go

@@ -19,15 +19,64 @@ package fake
 import (
 import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"io"
 	"math/rand"
 	"math/rand"
 
 
 	"github.com/cyberark/conjur-api-go/conjurapi"
 	"github.com/cyberark/conjur-api-go/conjurapi"
 )
 )
 
 
+type AddSecretCall struct {
+	Variable string
+	Value    string
+}
+
+type LoadPolicyCall struct {
+	PolicyID string
+	Policy   string
+}
+
 type ConjurMockClient struct {
 type ConjurMockClient struct {
+	AddSecretCalls  []AddSecretCall
+	LoadPolicyCalls []LoadPolicyCall
+	SecretDetails   map[string]*conjurapi.StaticSecretResponse
+	SecretValues    map[string][]byte
+}
+
+func (mc *ConjurMockClient) AddSecret(variable, secret string) error {
+	mc.AddSecretCalls = append(mc.AddSecretCalls, AddSecretCall{
+		Variable: variable,
+		Value:    secret,
+	})
+	return nil
+}
+
+func (mc *ConjurMockClient) GetStaticSecretDetails(id string) (*conjurapi.StaticSecretResponse, error) {
+	if mc.SecretDetails == nil || mc.SecretDetails[id] == nil {
+		return &conjurapi.StaticSecretResponse{
+			StaticSecret: conjurapi.StaticSecret{
+				Annotations: map[string]string{
+					"managed-by": "external-secrets",
+				},
+			},
+			Permissions: conjurapi.Permission{},
+		}, nil
+	}
+	return mc.SecretDetails[id], nil
+}
+
+func (mc *ConjurMockClient) LoadPolicy(policyMode conjurapi.PolicyMode, policyID string, policy io.Reader) (*conjurapi.PolicyResponse, error) {
+	body, _ := io.ReadAll(policy)
+	mc.LoadPolicyCalls = append(mc.LoadPolicyCalls, LoadPolicyCall{
+		PolicyID: policyID,
+		Policy:   string(body),
+	})
+	return &conjurapi.PolicyResponse{}, nil
 }
 }
 
 
 func (mc *ConjurMockClient) RetrieveSecret(secret string) (result []byte, err error) {
 func (mc *ConjurMockClient) RetrieveSecret(secret string) (result []byte, err error) {
+	if value, ok := mc.SecretValues[secret]; ok {
+		return value, nil
+	}
 	if secret == "error" {
 	if secret == "error" {
 		err = errors.New("error")
 		err = errors.New("error")
 		return nil, err
 		return nil, err

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

@@ -4,12 +4,16 @@ go 1.26.5
 
 
 require (
 require (
 	github.com/cyberark/conjur-api-go v0.14.1
 	github.com/cyberark/conjur-api-go v0.14.1
+	github.com/doodlesbykumbi/conjur-policy-go v0.0.0-20241022093904-5fc5921aee96
 	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
 	github.com/google/go-cmp v0.7.0
 	github.com/google/go-cmp v0.7.0
+	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/gjson v1.18.0
+	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/api v0.36.3
 	k8s.io/api v0.36.3
+	k8s.io/apiextensions-apiserver v0.36.3
 	k8s.io/apimachinery v0.36.3
 	k8s.io/apimachinery v0.36.3
 	k8s.io/client-go v0.36.3
 	k8s.io/client-go v0.36.3
 	sigs.k8s.io/controller-runtime v0.24.1
 	sigs.k8s.io/controller-runtime v0.24.1
@@ -103,7 +107,6 @@ require (
 	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
 	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // 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
-	k8s.io/apiextensions-apiserver v0.36.3 // indirect
 	k8s.io/klog/v2 v2.140.0 // indirect
 	k8s.io/klog/v2 v2.140.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
 	k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
 	k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
 	k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect

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

@@ -48,6 +48,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/doodlesbykumbi/conjur-policy-go v0.0.0-20241022093904-5fc5921aee96 h1:AkFmClMkf4kOmsgSO9UD+PAZINmON40m44PNucHuDlo=
+github.com/doodlesbykumbi/conjur-policy-go v0.0.0-20241022093904-5fc5921aee96/go.mod h1:3M6KY0++RSAgFkJlFYAkl2/kLdwT7j2Pr4L33dMAtrw=
 github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
 github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
 github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
 github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
 github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
 github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=

+ 2 - 2
providers/v1/conjur/provider.go

@@ -52,9 +52,9 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 }
 }
 
 
 // Capabilities returns the provider's supported capabilities.
 // Capabilities returns the provider's supported capabilities.
-// Conjur provider supports read-only access to secrets.
+// Conjur provider supports read-write access to secrets.
 func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
 func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
-	return esv1.SecretStoreReadOnly
+	return esv1.SecretStoreReadWrite
 }
 }
 
 
 // newConjurProvider creates and returns a new Conjur client with the specified configuration.
 // newConjurProvider creates and returns a new Conjur client with the specified configuration.