Browse Source

feat: add caching to the 1Password SDK provider (#5811)

Gergely Bräutigam 3 months ago
parent
commit
c19175a757

+ 25 - 0
apis/externalsecrets/v1/secretstore_onepassword_sdk_types.go

@@ -17,6 +17,8 @@ limitations under the License.
 package v1
 
 import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
 	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
 )
 
@@ -36,6 +38,22 @@ type IntegrationInfo struct {
 	Version string `json:"version,omitempty"`
 }
 
+// CacheConfig configures client-side caching for read operations.
+type CacheConfig struct {
+	// TTL is the time-to-live for cached secrets.
+	// Format: duration string (e.g., "5m", "1h", "30s")
+	// +kubebuilder:default="5m"
+	// +optional
+	TTL metav1.Duration `json:"ttl,omitempty"`
+
+	// MaxSize is the maximum number of secrets to cache.
+	// When the cache is full, least-recently-used entries are evicted.
+	// +kubebuilder:default=100
+	// +kubebuilder:validation:Minimum=1
+	// +optional
+	MaxSize int `json:"maxSize,omitempty"`
+}
+
 // OnePasswordSDKProvider configures a store to sync secrets using the 1Password sdk.
 type OnePasswordSDKProvider struct {
 	// Vault defines the vault's name or uuid to access. Do NOT add op:// prefix. This will be done automatically.
@@ -46,4 +64,11 @@ type OnePasswordSDKProvider struct {
 	IntegrationInfo *IntegrationInfo `json:"integrationInfo,omitempty"`
 	// Auth defines the information necessary to authenticate against OnePassword API.
 	Auth *OnePasswordSDKAuth `json:"auth"`
+	// Cache configures client-side caching for read operations (GetSecret, GetSecretMap).
+	// When enabled, secrets are cached with the specified TTL.
+	// Write operations (PushSecret, DeleteSecret) automatically invalidate relevant cache entries.
+	// If omitted, caching is disabled (default).
+	// cache: {} is a valid option to set.
+	// +optional
+	Cache *CacheConfig `json:"cache,omitempty"`
 }

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

@@ -814,6 +814,22 @@ func (in *CSMAuthSecretRef) DeepCopy() *CSMAuthSecretRef {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CacheConfig) DeepCopyInto(out *CacheConfig) {
+	*out = *in
+	out.TTL = in.TTL
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CacheConfig.
+func (in *CacheConfig) DeepCopy() *CacheConfig {
+	if in == nil {
+		return nil
+	}
+	out := new(CacheConfig)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *CertAuth) DeepCopyInto(out *CertAuth) {
 	*out = *in
 	in.ClientCert.DeepCopyInto(&out.ClientCert)
@@ -2907,6 +2923,11 @@ func (in *OnePasswordSDKProvider) DeepCopyInto(out *OnePasswordSDKProvider) {
 		*out = new(OnePasswordSDKAuth)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.Cache != nil {
+		in, out := &in.Cache, &out.Cache
+		*out = new(CacheConfig)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OnePasswordSDKProvider.

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

@@ -3865,6 +3865,28 @@ spec:
                         required:
                         - serviceAccountSecretRef
                         type: object
+                      cache:
+                        description: |-
+                          Cache configures client-side caching for read operations (GetSecret, GetSecretMap).
+                          When enabled, secrets are cached with the specified TTL.
+                          Write operations (PushSecret, DeleteSecret) automatically invalidate relevant cache entries.
+                          If omitted, caching is disabled (default).
+                          cache: {} is a valid option to set.
+                        properties:
+                          maxSize:
+                            default: 100
+                            description: |-
+                              MaxSize is the maximum number of secrets to cache.
+                              When the cache is full, least-recently-used entries are evicted.
+                            minimum: 1
+                            type: integer
+                          ttl:
+                            default: 5m
+                            description: |-
+                              TTL is the time-to-live for cached secrets.
+                              Format: duration string (e.g., "5m", "1h", "30s")
+                            type: string
+                        type: object
                       integrationInfo:
                         description: |-
                           IntegrationInfo specifies the name and version of the integration built using the 1Password Go SDK.

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

@@ -3865,6 +3865,28 @@ spec:
                         required:
                         - serviceAccountSecretRef
                         type: object
+                      cache:
+                        description: |-
+                          Cache configures client-side caching for read operations (GetSecret, GetSecretMap).
+                          When enabled, secrets are cached with the specified TTL.
+                          Write operations (PushSecret, DeleteSecret) automatically invalidate relevant cache entries.
+                          If omitted, caching is disabled (default).
+                          cache: {} is a valid option to set.
+                        properties:
+                          maxSize:
+                            default: 100
+                            description: |-
+                              MaxSize is the maximum number of secrets to cache.
+                              When the cache is full, least-recently-used entries are evicted.
+                            minimum: 1
+                            type: integer
+                          ttl:
+                            default: 5m
+                            description: |-
+                              TTL is the time-to-live for cached secrets.
+                              Format: duration string (e.g., "5m", "1h", "30s")
+                            type: string
+                        type: object
                       integrationInfo:
                         description: |-
                           IntegrationInfo specifies the name and version of the integration built using the 1Password Go SDK.

+ 44 - 0
deploy/crds/bundle.yaml

@@ -5683,6 +5683,28 @@ spec:
                           required:
                             - serviceAccountSecretRef
                           type: object
+                        cache:
+                          description: |-
+                            Cache configures client-side caching for read operations (GetSecret, GetSecretMap).
+                            When enabled, secrets are cached with the specified TTL.
+                            Write operations (PushSecret, DeleteSecret) automatically invalidate relevant cache entries.
+                            If omitted, caching is disabled (default).
+                            cache: {} is a valid option to set.
+                          properties:
+                            maxSize:
+                              default: 100
+                              description: |-
+                                MaxSize is the maximum number of secrets to cache.
+                                When the cache is full, least-recently-used entries are evicted.
+                              minimum: 1
+                              type: integer
+                            ttl:
+                              default: 5m
+                              description: |-
+                                TTL is the time-to-live for cached secrets.
+                                Format: duration string (e.g., "5m", "1h", "30s")
+                              type: string
+                          type: object
                         integrationInfo:
                           description: |-
                             IntegrationInfo specifies the name and version of the integration built using the 1Password Go SDK.
@@ -17349,6 +17371,28 @@ spec:
                           required:
                             - serviceAccountSecretRef
                           type: object
+                        cache:
+                          description: |-
+                            Cache configures client-side caching for read operations (GetSecret, GetSecretMap).
+                            When enabled, secrets are cached with the specified TTL.
+                            Write operations (PushSecret, DeleteSecret) automatically invalidate relevant cache entries.
+                            If omitted, caching is disabled (default).
+                            cache: {} is a valid option to set.
+                          properties:
+                            maxSize:
+                              default: 100
+                              description: |-
+                                MaxSize is the maximum number of secrets to cache.
+                                When the cache is full, least-recently-used entries are evicted.
+                              minimum: 1
+                              type: integer
+                            ttl:
+                              default: 5m
+                              description: |-
+                                TTL is the time-to-live for cached secrets.
+                                Format: duration string (e.g., "5m", "1h", "30s")
+                              type: string
+                          type: object
                         integrationInfo:
                           description: |-
                             IntegrationInfo specifies the name and version of the integration built using the 1Password Go SDK.

+ 65 - 0
docs/api/spec.md

@@ -2119,6 +2119,53 @@ External Secrets meta/v1.SecretKeySelector
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1.CacheConfig">CacheConfig
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.OnePasswordSDKProvider">OnePasswordSDKProvider</a>)
+</p>
+<p>
+<p>CacheConfig configures client-side caching for read operations.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>ttl</code></br>
+<em>
+<a href="https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#Duration">
+Kubernetes meta/v1.Duration
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>TTL is the time-to-live for cached secrets.
+Format: duration string (e.g., &ldquo;5m&rdquo;, &ldquo;1h&rdquo;, &ldquo;30s&rdquo;)</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>maxSize</code></br>
+<em>
+int
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>MaxSize is the maximum number of secrets to cache.
+When the cache is full, least-recently-used entries are evicted.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1.CertAuth">CertAuth
 </h3>
 <p>
@@ -7868,6 +7915,24 @@ OnePasswordSDKAuth
 <p>Auth defines the information necessary to authenticate against OnePassword API.</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>cache</code></br>
+<em>
+<a href="#external-secrets.io/v1.CacheConfig">
+CacheConfig
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Cache configures client-side caching for read operations (GetSecret, GetSecretMap).
+When enabled, secrets are cached with the specified TTL.
+Write operations (PushSecret, DeleteSecret) automatically invalidate relevant cache entries.
+If omitted, caching is disabled (default).
+cache: {} is a valid option to set.</p>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1.OracleAuth">OracleAuth

+ 27 - 0
docs/provider/1password-sdk.md

@@ -18,6 +18,33 @@ A sample store configuration looks like this:
 {% include '1passwordsdk-secret-store.yaml' %}
 ```
 
+### Client-Side Caching
+
+Optional client-side caching reduces 1Password API calls. Configure TTL and cache size in the store:
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: 1password-cached
+spec:
+  provider:
+    onepasswordSDK:
+      vault: production
+      auth:
+        serviceAccountSecretRef:
+          name: op-token
+          key: token
+      cache:
+        ttl: 5m      # Optional, default: 5m
+        maxSize: 100 # Optional, default: 100
+```
+
+Caching applies to read operations (`GetSecret`, `GetSecretMap`). Write operations (`PushSecret`, `DeleteSecret`) automatically invalidate relevant cache entries.
+
+!!! warning "Experimental"
+    This is an experimental feature and if too long of a TTL is set, secret information might be out of date.
+
 ### GetSecret
 
 Valid secret references should use the following key format: `<item>/[section/]<field>`.

+ 79 - 5
providers/v1/onepasswordsdk/client.go

@@ -19,6 +19,7 @@ package onepasswordsdk
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"strings"
@@ -53,11 +54,20 @@ func (p *Provider) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRem
 		return nil, errors.New(errVersionNotImplemented)
 	}
 	key := p.constructRefKey(ref.Key)
+
+	if cached, ok := p.cacheGet(key); ok {
+		return cached, nil
+	}
+
 	secret, err := p.client.Secrets().Resolve(ctx, key)
 	if err != nil {
 		return nil, err
 	}
-	return []byte(secret), nil
+
+	result := []byte(secret)
+	p.cacheAdd(key, result)
+
+	return result, nil
 }
 
 // Close closes the client connection.
@@ -87,12 +97,16 @@ func (p *Provider) DeleteSecret(ctx context.Context, ref esv1.PushSecretRemoteRe
 		if err = p.client.Items().Delete(ctx, providerItem.VaultID, providerItem.ID); err != nil {
 			return fmt.Errorf("failed to delete item: %w", err)
 		}
+		p.invalidateCacheByPrefix(p.constructRefKey(ref.GetRemoteKey()))
 		return nil
 	}
 
 	if _, err = p.client.Items().Put(ctx, providerItem); err != nil {
 		return fmt.Errorf("failed to update item: %w", err)
 	}
+
+	p.invalidateCacheByPrefix(p.constructRefKey(ref.GetRemoteKey()))
+
 	return nil
 }
 
@@ -128,17 +142,37 @@ func (p *Provider) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretData
 		return nil, errors.New(errVersionNotImplemented)
 	}
 
+	cacheKey := p.constructRefKey(ref.Key) + "|" + ref.Property
+	if cached, ok := p.cacheGet(cacheKey); ok {
+		var result map[string][]byte
+		if err := json.Unmarshal(cached, &result); err == nil {
+			return result, nil
+		}
+		// continue with fresh instead
+	}
+
 	item, err := p.findItem(ctx, ref.Key)
 	if err != nil {
 		return nil, err
 	}
 
+	var result map[string][]byte
 	propertyType, property := getObjType(item.Category, ref.Property)
 	if propertyType == filePrefix {
-		return p.getFiles(ctx, item, property)
+		result, err = p.getFiles(ctx, item, property)
+	} else {
+		result, err = p.getFields(item, property)
 	}
 
-	return p.getFields(item, property)
+	if err != nil {
+		return nil, err
+	}
+
+	if serialized, err := json.Marshal(result); err == nil {
+		p.cacheAdd(cacheKey, serialized)
+	}
+
+	return result, nil
 }
 
 func (p *Provider) getFields(item onepassword.Item, property string) (map[string][]byte, error) {
@@ -205,13 +239,11 @@ func getObjType(documentType onepassword.ItemCategory, property string) (string,
 
 // createItem creates a new item in the first vault. If no vaults exist, it returns an error.
 func (p *Provider) createItem(ctx context.Context, val []byte, ref esv1.PushSecretData) error {
-	// Get the metadata
 	mdata, err := metadata.ParseMetadataParameters[PushSecretMetadataSpec](ref.GetMetadata())
 	if err != nil {
 		return fmt.Errorf("failed to parse push secret metadata: %w", err)
 	}
 
-	// Get the label
 	label := ref.GetProperty()
 	if label == "" {
 		label = "password"
@@ -236,6 +268,8 @@ func (p *Provider) createItem(ctx context.Context, val []byte, ref esv1.PushSecr
 		return fmt.Errorf("failed to create item: %w", err)
 	}
 
+	p.invalidateCacheByPrefix(p.constructRefKey(ref.GetRemoteKey()))
+
 	return nil
 }
 
@@ -326,6 +360,8 @@ func (p *Provider) PushSecret(ctx context.Context, secret *corev1.Secret, ref es
 		return fmt.Errorf("failed to update item: %w", err)
 	}
 
+	p.invalidateCacheByPrefix(p.constructRefKey(title))
+
 	return nil
 }
 
@@ -390,3 +426,41 @@ func (p *Provider) constructRefKey(key string) string {
 	// remove any possible leading slashes because the vaultPrefix already contains it.
 	return p.vaultPrefix + strings.TrimPrefix(key, "/")
 }
+
+// cacheGet retrieves a value from the cache. Returns false if cache is disabled or key not found.
+func (p *Provider) cacheGet(key string) ([]byte, bool) {
+	if p.cache == nil {
+		return nil, false
+	}
+	return p.cache.Get(key)
+}
+
+// cacheAdd stores a value in the cache. No-op if cache is disabled.
+func (p *Provider) cacheAdd(key string, value []byte) {
+	if p.cache == nil {
+		return
+	}
+	p.cache.Add(key, value)
+}
+
+// invalidateCacheByPrefix removes all cache entries that start with the given prefix.
+// This is used to invalidate cache entries when an item is modified or deleted.
+// No-op if cache is disabled.
+// Why are we using a Prefix? Because items and properties are stored via prefixes using 1Password SDK.
+// This means when an item is deleted we delete the fields and properties that belong to the item as well.
+func (p *Provider) invalidateCacheByPrefix(prefix string) {
+	if p.cache == nil {
+		return
+	}
+
+	keys := p.cache.Keys()
+	for _, key := range keys {
+		if strings.HasPrefix(key, prefix) {
+			// if exact match, or ends in `/` or `|` we can remove it.
+			// this will clear all fields and properties for this entry.
+			if len(key) == len(prefix) || key[len(prefix)] == '/' || key[len(prefix)] == '|' {
+				p.cache.Remove(key)
+			}
+		}
+	}
+}

+ 375 - 5
providers/v1/onepasswordsdk/client_test.go

@@ -20,8 +20,10 @@ import (
 	"context"
 	"errors"
 	"testing"
+	"time"
 
 	"github.com/1password/onepassword-sdk-go"
+	"github.com/hashicorp/golang-lru/v2/expirable"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
@@ -102,7 +104,7 @@ func TestProviderGetSecret(t *testing.T) {
 				client:      tt.client(),
 				vaultPrefix: "op://vault/",
 			}
-			got, err := p.GetSecret(context.Background(), tt.ref)
+			got, err := p.GetSecret(t.Context(), tt.ref)
 			tt.assertError(t, err)
 			require.Equal(t, string(got), string(tt.want))
 		})
@@ -272,7 +274,7 @@ func TestProviderGetSecretMap(t *testing.T) {
 				client:      tt.client(),
 				vaultPrefix: "op://vault/",
 			}
-			got, err := p.GetSecretMap(context.Background(), tt.ref)
+			got, err := p.GetSecretMap(t.Context(), tt.ref)
 			tt.assertError(t, err)
 			require.Equal(t, tt.want, got)
 		})
@@ -415,7 +417,7 @@ func TestPushSecret(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			ctx := context.Background()
+			ctx := t.Context()
 			lister := tt.lister()
 			p := &Provider{
 				client: &onepassword.Client{
@@ -541,7 +543,7 @@ func TestDeleteItemField(t *testing.T) {
 
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			ctx := context.Background()
+			ctx := t.Context()
 			lister := testCase.lister()
 			p := &Provider{
 				client: &onepassword.Client{
@@ -577,7 +579,7 @@ func TestGetVault(t *testing.T) {
 
 	for _, titleOrUuid := range titleOrUuids {
 		t.Run(titleOrUuid, func(t *testing.T) {
-			vaultID, err := p.GetVault(context.Background(), titleOrUuid)
+			vaultID, err := p.GetVault(t.Context(), titleOrUuid)
 			require.NoError(t, err)
 			require.Equal(t, fc.listAllResult[0].ID, vaultID)
 		})
@@ -671,6 +673,374 @@ func (f *fakeClient) ResolveAll(ctx context.Context, secretReferences []string)
 	return f.resolveAll, f.resolveAllError
 }
 
+func TestCachingGetSecret(t *testing.T) {
+	t.Run("cache hit returns cached value", func(t *testing.T) {
+		fcWithCounter := &fakeClientWithCounter{
+			fakeClient: &fakeClient{
+				resolveResult: "secret-value",
+			},
+		}
+
+		p := &Provider{
+			client: &onepassword.Client{
+				SecretsAPI: fcWithCounter,
+				VaultsAPI:  fcWithCounter.fakeClient,
+			},
+			vaultPrefix: "op://vault/",
+		}
+
+		// Initialize cache
+		p.cache = expirable.NewLRU[string, []byte](100, nil, time.Minute)
+
+		ref := v1.ExternalSecretDataRemoteRef{Key: "item/field"}
+
+		// First call - cache miss
+		val1, err := p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, []byte("secret-value"), val1)
+		assert.Equal(t, 1, fcWithCounter.resolveCallCount)
+
+		// Second call - cache hit, should not call API
+		val2, err := p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, []byte("secret-value"), val2)
+		assert.Equal(t, 1, fcWithCounter.resolveCallCount, "API should not be called on cache hit")
+	})
+
+	t.Run("cache disabled works normally", func(t *testing.T) {
+		fcWithCounter := &fakeClientWithCounter{
+			fakeClient: &fakeClient{
+				resolveResult: "secret-value",
+			},
+		}
+
+		p := &Provider{
+			client: &onepassword.Client{
+				SecretsAPI: fcWithCounter,
+				VaultsAPI:  fcWithCounter.fakeClient,
+			},
+			vaultPrefix: "op://vault/",
+			cache:       nil, // Cache disabled
+		}
+
+		ref := v1.ExternalSecretDataRemoteRef{Key: "item/field"}
+
+		// Multiple calls should always hit API
+		_, err := p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, 1, fcWithCounter.resolveCallCount)
+
+		_, err = p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, 2, fcWithCounter.resolveCallCount)
+	})
+}
+
+func TestCachingGetSecretMap(t *testing.T) {
+	t.Run("cache hit returns cached map", func(t *testing.T) {
+		fc := &fakeClient{}
+		flWithCounter := &fakeListerWithCounter{
+			fakeLister: &fakeLister{
+				listAllResult: []onepassword.ItemOverview{
+					{
+						ID:       "item-id",
+						Title:    "item",
+						Category: "login",
+						VaultID:  "vault-id",
+					},
+				},
+				getResult: onepassword.Item{
+					ID:       "item-id",
+					Title:    "item",
+					Category: "login",
+					VaultID:  "vault-id",
+					Fields: []onepassword.ItemField{
+						{Title: "username", Value: "user1"},
+						{Title: "password", Value: "pass1"},
+					},
+				},
+			},
+		}
+
+		p := &Provider{
+			client: &onepassword.Client{
+				SecretsAPI: fc,
+				VaultsAPI:  fc,
+				ItemsAPI:   flWithCounter,
+			},
+			vaultPrefix: "op://vault/",
+			vaultID:     "vault-id",
+			cache:       expirable.NewLRU[string, []byte](100, nil, time.Minute),
+		}
+
+		ref := v1.ExternalSecretDataRemoteRef{Key: "item"}
+
+		// First call - cache miss
+		val1, err := p.GetSecretMap(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, map[string][]byte{
+			"username": []byte("user1"),
+			"password": []byte("pass1"),
+		}, val1)
+		assert.Equal(t, 1, flWithCounter.getCallCount)
+
+		// Second call - cache hit
+		val2, err := p.GetSecretMap(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, val1, val2)
+		assert.Equal(t, 1, flWithCounter.getCallCount, "API should not be called on cache hit")
+	})
+}
+
+func TestCacheInvalidationPushSecret(t *testing.T) {
+	t.Run("push secret invalidates cache", func(t *testing.T) {
+		fcWithCounter := &fakeClientWithCounter{
+			fakeClient: &fakeClient{
+				resolveResult: "secret-value",
+			},
+		}
+
+		fl := &fakeLister{
+			listAllResult: []onepassword.ItemOverview{
+				{ID: "item-id", Title: "item", VaultID: "vault-id"},
+			},
+			getResult: onepassword.Item{
+				ID:      "item-id",
+				Title:   "item",
+				VaultID: "vault-id",
+				Fields:  []onepassword.ItemField{{Title: "password", Value: "old"}},
+			},
+		}
+
+		p := &Provider{
+			client: &onepassword.Client{
+				SecretsAPI: fcWithCounter,
+				VaultsAPI:  fcWithCounter.fakeClient,
+				ItemsAPI:   fl,
+			},
+			vaultPrefix: "op://vault/",
+			vaultID:     "vault-id",
+			cache:       expirable.NewLRU[string, []byte](100, nil, time.Minute),
+		}
+
+		ref := v1.ExternalSecretDataRemoteRef{Key: "item/password"}
+
+		// Populate cache
+		val1, err := p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, []byte("secret-value"), val1)
+		assert.Equal(t, 1, fcWithCounter.resolveCallCount)
+
+		// Push new value (should invalidate cache)
+		pushRef := v1alpha1.PushSecretData{
+			Match: v1alpha1.PushSecretMatch{
+				SecretKey: "key",
+				RemoteRef: v1alpha1.PushSecretRemoteRef{
+					RemoteKey: "item",
+					Property:  "password",
+				},
+			},
+		}
+		secret := &corev1.Secret{
+			Data: map[string][]byte{"key": []byte("new-value")},
+		}
+		err = p.PushSecret(t.Context(), secret, pushRef)
+		require.NoError(t, err)
+
+		// Next GetSecret should fetch fresh value (cache was invalidated)
+		val2, err := p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, []byte("secret-value"), val2)
+		assert.Equal(t, 2, fcWithCounter.resolveCallCount, "Cache should have been invalidated")
+	})
+}
+
+func TestCacheInvalidationDeleteSecret(t *testing.T) {
+	t.Run("delete secret invalidates cache", func(t *testing.T) {
+		fcWithCounter := &fakeClientWithCounter{
+			fakeClient: &fakeClient{
+				resolveResult: "cached-value",
+			},
+		}
+
+		fl := &fakeLister{
+			listAllResult: []onepassword.ItemOverview{
+				{ID: "item-id", Title: "item", VaultID: "vault-id"},
+			},
+			getResult: onepassword.Item{
+				ID:      "item-id",
+				Title:   "item",
+				VaultID: "vault-id",
+				Fields: []onepassword.ItemField{
+					{Title: "field1", Value: "val1"},
+					{Title: "field2", Value: "val2"},
+				},
+			},
+		}
+
+		p := &Provider{
+			client: &onepassword.Client{
+				SecretsAPI: fcWithCounter,
+				VaultsAPI:  fcWithCounter.fakeClient,
+				ItemsAPI:   fl,
+			},
+			vaultPrefix: "op://vault/",
+			vaultID:     "vault-id",
+			cache:       expirable.NewLRU[string, []byte](100, nil, time.Minute),
+		}
+
+		ref := v1.ExternalSecretDataRemoteRef{Key: "item/field1"}
+
+		// Populate cache
+		_, err := p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, 1, fcWithCounter.resolveCallCount)
+
+		// Delete field (should invalidate cache)
+		deleteRef := v1alpha1.PushSecretRemoteRef{
+			RemoteKey: "item",
+			Property:  "field1",
+		}
+		err = p.DeleteSecret(t.Context(), deleteRef)
+		require.NoError(t, err)
+
+		// Next GetSecret should miss cache
+		_, err = p.GetSecret(t.Context(), ref)
+		require.NoError(t, err)
+		assert.Equal(t, 2, fcWithCounter.resolveCallCount, "Cache should have been invalidated")
+	})
+}
+
+func TestInvalidateCacheByPrefix(t *testing.T) {
+	t.Run("invalidates all entries with prefix", func(t *testing.T) {
+		p := &Provider{
+			vaultPrefix: "op://vault/",
+			cache:       expirable.NewLRU[string, []byte](100, nil, time.Minute),
+		}
+
+		// Add multiple cache entries
+		p.cache.Add("op://vault/item1/field1", []byte("val1"))
+		p.cache.Add("op://vault/item1/field2", []byte("val2"))
+		p.cache.Add("op://vault/item2/field1", []byte("val3"))
+
+		// Invalidate item1 entries
+		p.invalidateCacheByPrefix("op://vault/item1")
+
+		// item1 entries should be gone
+		_, ok1 := p.cache.Get("op://vault/item1/field1")
+		assert.False(t, ok1)
+		_, ok2 := p.cache.Get("op://vault/item1/field2")
+		assert.False(t, ok2)
+
+		// item2 entry should still exist
+		val3, ok3 := p.cache.Get("op://vault/item2/field1")
+		assert.True(t, ok3)
+		assert.Equal(t, []byte("val3"), val3)
+	})
+
+	t.Run("handles nil cache gracefully", func(t *testing.T) {
+		p := &Provider{
+			vaultPrefix: "op://vault/",
+			cache:       nil,
+		}
+
+		// Should not panic
+		p.invalidateCacheByPrefix("op://vault/item1")
+	})
+
+	t.Run("does not invalidate entries with similar prefixes", func(t *testing.T) {
+		p := &Provider{
+			vaultPrefix: "op://vault/",
+			cache:       expirable.NewLRU[string, []byte](100, nil, time.Minute),
+		}
+
+		p.cache.Add("op://vault/item/field1", []byte("val1"))
+		p.cache.Add("op://vault/item/field2", []byte("val2"))
+		p.cache.Add("op://vault/item|property", []byte("val3"))
+		p.cache.Add("op://vault/item-backup/field1", []byte("val4"))
+		p.cache.Add("op://vault/prod-db/secret", []byte("val5"))
+		p.cache.Add("op://vault/prod-db-replica/secret", []byte("val6"))
+		p.cache.Add("op://vault/prod-db-replica/secret|property", []byte("val7"))
+
+		p.invalidateCacheByPrefix("op://vault/item")
+
+		_, ok1 := p.cache.Get("op://vault/item/field1")
+		assert.False(t, ok1)
+		_, ok2 := p.cache.Get("op://vault/item/field2")
+		assert.False(t, ok2)
+		_, ok3 := p.cache.Get("op://vault/item|property")
+		assert.False(t, ok3)
+
+		val4, ok4 := p.cache.Get("op://vault/item-backup/field1")
+		assert.True(t, ok4, "item-backup should not be invalidated")
+		assert.Equal(t, []byte("val4"), val4)
+
+		p.invalidateCacheByPrefix("op://vault/prod-db")
+		_, ok5 := p.cache.Get("op://vault/prod-db/secret")
+		assert.False(t, ok5)
+
+		val6, ok6 := p.cache.Get("op://vault/prod-db-replica/secret")
+		assert.True(t, ok6, "prod-db-replica/secret should not be invalidated")
+		assert.Equal(t, []byte("val6"), val6)
+
+		val7, ok7 := p.cache.Get("op://vault/prod-db-replica/secret|property")
+		assert.True(t, ok7, "prod-db-replica/secret|property should not be invalidated")
+		assert.Equal(t, []byte("val7"), val7)
+	})
+}
+
+// fakeClientWithCounter wraps fakeClient and tracks Resolve call count.
+type fakeClientWithCounter struct {
+	*fakeClient
+	resolveCallCount int
+}
+
+func (f *fakeClientWithCounter) Resolve(ctx context.Context, secretReference string) (string, error) {
+	f.resolveCallCount++
+	return f.fakeClient.Resolve(ctx, secretReference)
+}
+
+// fakeListerWithCounter wraps fakeLister and tracks Get call count.
+type fakeListerWithCounter struct {
+	*fakeLister
+	getCallCount int
+}
+
+func (f *fakeListerWithCounter) Get(ctx context.Context, vaultID, itemID string) (onepassword.Item, error) {
+	f.getCallCount++
+	return f.fakeLister.Get(ctx, vaultID, itemID)
+}
+
+func (f *fakeListerWithCounter) Put(ctx context.Context, item onepassword.Item) (onepassword.Item, error) {
+	return f.fakeLister.Put(ctx, item)
+}
+
+func (f *fakeListerWithCounter) Delete(ctx context.Context, vaultID, itemID string) error {
+	return f.fakeLister.Delete(ctx, vaultID, itemID)
+}
+
+func (f *fakeListerWithCounter) Archive(ctx context.Context, vaultID, itemID string) error {
+	return f.fakeLister.Archive(ctx, vaultID, itemID)
+}
+
+func (f *fakeListerWithCounter) List(ctx context.Context, vaultID string, opts ...onepassword.ItemListFilter) ([]onepassword.ItemOverview, error) {
+	return f.fakeLister.List(ctx, vaultID, opts...)
+}
+
+func (f *fakeListerWithCounter) Shares() onepassword.ItemsSharesAPI {
+	return f.fakeLister.Shares()
+}
+
+func (f *fakeListerWithCounter) Files() onepassword.ItemsFilesAPI {
+	return f.fakeLister.Files()
+}
+
+func (f *fakeListerWithCounter) Create(ctx context.Context, item onepassword.ItemCreateParams) (onepassword.Item, error) {
+	return f.fakeLister.Create(ctx, item)
+}
+
 var _ onepassword.SecretsAPI = &fakeClient{}
 var _ onepassword.VaultsAPI = &fakeClient{}
 var _ onepassword.ItemsAPI = &fakeLister{}
+var _ onepassword.SecretsAPI = &fakeClientWithCounter{}
+var _ onepassword.ItemsAPI = &fakeListerWithCounter{}

+ 1 - 0
providers/v1/onepasswordsdk/go.mod

@@ -6,6 +6,7 @@ require (
 	github.com/1password/onepassword-sdk-go v0.3.1
 	github.com/external-secrets/external-secrets/apis v0.0.0
 	github.com/external-secrets/external-secrets/runtime v0.0.0
+	github.com/hashicorp/golang-lru/v2 v2.0.7
 	github.com/stretchr/testify v1.11.1
 	k8s.io/api v0.34.1
 	k8s.io/apimachinery v0.34.1

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

@@ -94,6 +94,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY
 github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
 github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5 h1:QCtizt3VTaANvnsd8TtD/eonx7JLIVdEKW1//ZNPZ9A=

+ 17 - 0
providers/v1/onepasswordsdk/provider.go

@@ -22,8 +22,10 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"time"
 
 	"github.com/1password/onepassword-sdk-go"
+	"github.com/hashicorp/golang-lru/v2/expirable"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
@@ -49,6 +51,7 @@ type Provider struct {
 	client      *onepassword.Client
 	vaultPrefix string
 	vaultID     string
+	cache       *expirable.LRU[string, []byte] // nil if caching is disabled
 }
 
 // NewClient constructs a new secrets client based on the provided store.
@@ -90,6 +93,20 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 	}
 	p.vaultID = vaultID
 
+	if config.Cache != nil {
+		ttl := 5 * time.Minute
+		if config.Cache.TTL.Duration > 0 {
+			ttl = config.Cache.TTL.Duration
+		}
+
+		maxSize := 100
+		if config.Cache.MaxSize > 0 {
+			maxSize = config.Cache.MaxSize
+		}
+
+		p.cache = expirable.NewLRU[string, []byte](maxSize, nil, ttl)
+	}
+
 	return p, nil
 }
 

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

@@ -582,6 +582,9 @@ spec:
           key: string
           name: string
           namespace: string
+      cache:
+        maxSize: 100
+        ttl: "5m"
       integrationInfo:
         name: "1Password SDK"
         version: "v1.0.0"

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

@@ -582,6 +582,9 @@ spec:
           key: string
           name: string
           namespace: string
+      cache:
+        maxSize: 100
+        ttl: "5m"
       integrationInfo:
         name: "1Password SDK"
         version: "v1.0.0"