Просмотр исходного кода

feat(onepasswordsdk): implement GetAllSecrets (#6445)

* onepasswordsdk: Implement GetAllSecrets

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* onepasswordsdk: Update provider docs and code snippets now that dataFrom.extract and dataFrom.find should be supported

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* onepasswordsdk: Allow caching of item list from vault

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* onepasswordsdk: Add tags example to doc snippet

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* Add GitHub issue reference for failing cache test between GetSecret and GetAllSecrets

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* Minor fixes from CodeRabbit

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* Reuse listItems cache in findItem. Reduce complexity flagged by SonarQube.

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* Change to `t.Skip()` instead of `if false` for disabled test

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

* Address comments from PR review

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>

---------

Signed-off-by: Caleb McKay <11079725+calebmckay@users.noreply.github.com>
Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Caleb McKay 6 дней назад
Родитель
Сommit
8c9cbff118

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

@@ -40,7 +40,7 @@ spec:
         maxSize: 100 # Optional, default: 100
 ```
 
-Caching applies to read operations (`GetSecret`, `GetSecretMap`). Write operations (`PushSecret`, `DeleteSecret`) automatically invalidate relevant cache entries.
+Caching applies to read operations (`GetSecret`, `GetSecretMap`, `GetAllSecrets`). 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.

+ 14 - 0
docs/snippets/1passwordsdk-external-secret.yaml

@@ -13,3 +13,17 @@ spec:
     - secretKey: test-login-1
       remoteRef:
         key: test-login-1/username
+  # OR
+  dataFrom:
+    - extract:
+        key: test-login-1
+        property: username # optional field Label to match exactly
+    # OR
+    - find:
+        path: my-env-config # optional Item Title to match exactly
+        name:
+          regexp: "^username$"
+    # OR
+    - find:
+        tags:
+          tag1: "" # optional tags to match - value is unused, just needs to be present

+ 255 - 42
providers/v1/onepasswordsdk/client.go

@@ -32,34 +32,39 @@ import (
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	"github.com/external-secrets/external-secrets/runtime/constants"
 	"github.com/external-secrets/external-secrets/runtime/esutils/metadata"
+	"github.com/external-secrets/external-secrets/runtime/find"
 	"github.com/external-secrets/external-secrets/runtime/metrics"
 )
 
 const (
-	fieldPrefix             = "field"
-	filePrefix              = "file"
-	prefixSplitter          = "/"
-	errExpectedOneFieldMsgF = "found more than 1 fields with title '%s' in '%s', got %d"
-	itemCachePrefix         = "item:"
-	fileCachePrefix         = "file:"
-	defaultFieldLabel       = "password"
-
-	errMsgUpdateItem    = "failed to update item: %w"
-	errMsgCreateItem    = "failed to create item: %w"
-	errMsgParsePushMeta = "failed to parse push secret metadata: %w"
+	fieldPrefix       = "field"
+	filePrefix        = "file"
+	prefixSplitter    = "/"
+	vaultCachePrefix  = "vault:"
+	itemCachePrefix   = "item:"
+	fileCachePrefix   = "file:"
+	defaultFieldLabel = "password"
+
+	errMsgUpdateItem       = "failed to update item: %w"
+	errMsgCreateItem       = "failed to create item: %w"
+	errMsgParsePushMeta    = "failed to parse push secret metadata: %w"
+	errMsgExpectedOneField = "found more than 1 fields with title '%s' in '%s', got %d"
+	errMsgExpectedOneFile  = "found more than 1 files with title '%s' in '%s', got %d"
+	errMsgFieldNotFound    = "field with label '%s' not found in item '%s'"
+	errMsgFileNotFound     = "file with title '%s' not found in item '%s'"
 )
 
 // ErrKeyNotFound is returned when a key is not found in the 1Password Vaults.
 var ErrKeyNotFound = errors.New("key not found")
 
-// nativeItemIDPattern matches a 1Password item ID per the Connect
-// server OpenAPI spec (^[\da-z]{26}$). Despite being called "UUIDs"
-// in 1Password's SDK and docs, they are not RFC 4122 UUIDs.
-// https://github.com/1Password/connect/blob/7485a59/docs/openapi/spec.yaml#L73-L75
-var nativeItemIDPattern = regexp.MustCompile(`^[\da-z]{26}$`)
+// nativeIDPattern matches a 1Password unique identifier per the SDK
+// docs (^[\da-z]{26}$). Despite being called "UUIDs" in 1Password's SDK and docs,
+// they are not RFC 4122 UUIDs.
+// https://www.1password.dev/cli/reference#unique-identifiers-ids
+var nativeIDPattern = regexp.MustCompile(`^[\da-z]{26}$`)
 
-func isNativeItemID(s string) bool {
-	return nativeItemIDPattern.MatchString(s)
+func isNativeID(s string) bool {
+	return nativeIDPattern.MatchString(s)
 }
 
 // PushSecretMetadataSpec defines the metadata configuration for pushing secrets to 1Password.
@@ -173,9 +178,74 @@ func deleteField(fields []onepassword.ItemField, title string) ([]onepassword.It
 	return fieldsF, found, nil
 }
 
-// GetAllSecrets Not Implemented.
-func (p *SecretsClient) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
-	return nil, fmt.Errorf(errOnePasswordSdkStore, errors.New(errNotImplemented))
+// GetAllSecrets syncs multiple 1Password Items into a single Kubernetes Secret, for dataFrom.find.
+func (p *SecretsClient) GetAllSecrets(ctx context.Context, ref esv1.ExternalSecretFind) (map[string][]byte, error) {
+	items, err := p.listItems(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	// If ref.Tags is set, filter to only items that match the given tags
+	if ref.Tags != nil {
+		var filteredItems []onepassword.ItemOverview
+		for _, item := range items {
+			if itemHasTags(ref.Tags, item.Tags) {
+				filteredItems = append(filteredItems, item)
+			}
+		}
+		items = filteredItems
+	}
+
+	secretData := make(map[string][]byte)
+	for _, overview := range items {
+		if ref.Path != nil && *ref.Path != overview.Title {
+			continue
+		}
+
+		if err := p.collectAllSecrets(ctx, overview.Title, ref, secretData); err != nil {
+			return nil, err
+		}
+	}
+
+	return secretData, nil
+}
+
+func (p *SecretsClient) collectAllSecrets(ctx context.Context, itemName string, ref esv1.ExternalSecretFind, secretData map[string][]byte) error {
+	item, err := p.findItem(ctx, itemName)
+	if err != nil {
+		return fmt.Errorf("failed to get item %s: %w", itemName, err)
+	}
+	if err := p.getAllFields(item, ref, secretData); err != nil {
+		return fmt.Errorf("failed to get fields for item %s: %w", itemName, err)
+	}
+	if err := p.getAllFiles(ctx, item, ref, secretData); err != nil {
+		return fmt.Errorf("failed to get files for item %s: %w", itemName, err)
+	}
+	return nil
+}
+
+// itemHasTags returns true if all required keys are present in the item's tags.
+func itemHasTags(required map[string]string, itemTags []string) bool {
+	// Quickly return false if this item has fewer tags than required, since it can't possibly match.
+	if len(itemTags) < len(required) {
+		return false
+	}
+
+	// Use a map to track which required tags we've found in the item's tags.
+	matchingTags := make(map[string]string)
+
+	// Loop through item's tags and add any matching tags to the matchingTags map.
+	for _, itemTag := range itemTags {
+		if _, ok := required[itemTag]; ok {
+			matchingTags[itemTag] = required[itemTag]
+		}
+	}
+
+	// Check if we found all required tags in the item's tags.
+	if len(matchingTags) < len(required) {
+		return false
+	}
+	return true
 }
 
 // GetSecretMap returns multiple k/v pairs from the provider, for dataFrom.extract.
@@ -217,14 +287,44 @@ func (p *SecretsClient) GetSecretMap(ctx context.Context, ref esv1.ExternalSecre
 	return result, nil
 }
 
+func (p *SecretsClient) listItems(ctx context.Context) ([]onepassword.ItemOverview, error) {
+	var items []onepassword.ItemOverview
+
+	cacheKey := vaultCachePrefix + p.vaultID
+	if cached, ok := p.cacheGet(cacheKey); ok {
+		if err := json.Unmarshal(cached, &items); err == nil {
+			return items, nil
+		}
+	}
+
+	// Vault item list not found in cache - fetch from the API
+	items, err := p.client.Items().List(ctx, p.vaultID)
+	metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKItemsList, err)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list items: %w", err)
+	}
+
+	// Add the vault list to the cache
+	if serialized, err := json.Marshal(items); err == nil {
+		p.cacheAdd(cacheKey, serialized)
+	} else {
+		// If we fail to serialize the items for caching, we can still return the items, so we just log the error and continue.
+		fmt.Printf("failed to serialize items for caching: %v\n", err)
+	}
+
+	return items, nil
+}
+
+// getFields gets the field matching the given property label in an item, or all fields in the item if `property` is not set.
 func (p *SecretsClient) getFields(item onepassword.Item, property string) (map[string][]byte, error) {
 	secretData := make(map[string][]byte)
 	for _, field := range item.Fields {
 		if property != "" && field.Title != property {
 			continue
 		}
+		// Throw error if there are multiple fields with the same label.
 		if length := countFieldsWithLabel(field.Title, item.Fields); length != 1 {
-			return nil, fmt.Errorf(errExpectedOneFieldMsgF, field.Title, item.Title, length)
+			return nil, fmt.Errorf(errMsgExpectedOneField, field.Title, item.Title, length)
 		}
 
 		// caution: do not use client.GetValue here because it has undesirable behavior on keys with a dot in them
@@ -234,6 +334,58 @@ func (p *SecretsClient) getFields(item onepassword.Item, property string) (map[s
 	return secretData, nil
 }
 
+// getAllFields retrieves all fields matching the given ref in an item, and adds them to the given secretData map.
+func (p *SecretsClient) getAllFields(item onepassword.Item, ref esv1.ExternalSecretFind, secretData map[string][]byte) error {
+	var matcher *find.Matcher
+	if ref.Name != nil {
+		var err error
+		matcher, err = find.New(*ref.Name)
+		if err != nil {
+			return err
+		}
+	}
+
+	for _, field := range item.Fields {
+		// Throw error if there are multiple fields in this item with the same label.
+		if length := countFieldsWithLabel(field.Title, item.Fields); length != 1 {
+			return fmt.Errorf(errMsgExpectedOneField, field.Title, item.Title, length)
+		}
+
+		// If ref.Name is set, only add fields that match the regex pattern.
+		if matcher != nil && !matcher.MatchName(field.Title) {
+			continue
+		}
+
+		// Throw error if there are multiple fields with the same label.
+		if _, found := secretData[field.Title]; found {
+			return fmt.Errorf("found multiple labels with the same key '%s'", field.Title)
+		}
+
+		secretData[field.Title] = []byte(field.Value)
+	}
+
+	return nil
+}
+
+// fetchFile retrieves the content of a file, using the cache if possible.
+// TODO - Currently, cached files are not invalidated on updates. This should be done as part of the cache refactor.
+// See GitHub issue: https://github.com/external-secrets/external-secrets/issues/6444
+func (p *SecretsClient) fetchFile(ctx context.Context, itemID, fieldID string, attributes onepassword.FileAttributes) ([]byte, error) {
+	cacheKey := fileCachePrefix + p.vaultID + ":" + itemID + ":" + fieldID + ":" + attributes.Name
+	if cached, ok := p.cacheGet(cacheKey); ok {
+		return cached, nil
+	}
+	contents, err := p.client.Items().Files().Read(ctx, p.vaultID, fieldID, attributes)
+	metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKFilesRead, err)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	p.cacheAdd(cacheKey, contents)
+	return contents, nil
+}
+
+// getFiles gets the file matching the given property label in an item, or all files in the item if `property` is not set.
 func (p *SecretsClient) getFiles(ctx context.Context, item onepassword.Item, property string) (map[string][]byte, error) {
 	secretData := make(map[string][]byte)
 	for _, file := range item.Files {
@@ -241,25 +393,52 @@ func (p *SecretsClient) getFiles(ctx context.Context, item onepassword.Item, pro
 			continue
 		}
 
-		cacheKey := fileCachePrefix + p.vaultID + ":" + item.ID + ":" + file.FieldID + ":" + file.Attributes.Name
-		if cached, ok := p.cacheGet(cacheKey); ok {
-			secretData[file.Attributes.Name] = cached
-			continue
+		// Throw error if there are multiple files with the same label.
+		if length := countFilesWithLabel(file.Attributes.Name, item.Files); length != 1 {
+			return nil, fmt.Errorf(errMsgExpectedOneFile, file.Attributes.Name, item.Title, length)
 		}
 
-		contents, err := p.client.Items().Files().Read(ctx, p.vaultID, file.FieldID, file.Attributes)
-		metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKFilesRead, err)
+		contents, err := p.fetchFile(ctx, item.ID, file.FieldID, file.Attributes)
 		if err != nil {
-			return nil, fmt.Errorf("failed to read file: %w", err)
+			return nil, err
 		}
-
-		p.cacheAdd(cacheKey, contents)
 		secretData[file.Attributes.Name] = contents
 	}
 
 	return secretData, nil
 }
 
+// getAllFiles retrieves all files matching the given ref in an item, and adds them to the given secretData map.
+func (p *SecretsClient) getAllFiles(ctx context.Context, item onepassword.Item, ref esv1.ExternalSecretFind, secretData map[string][]byte) error {
+	var matcher *find.Matcher
+	if ref.Name != nil {
+		var err error
+		matcher, err = find.New(*ref.Name)
+		if err != nil {
+			return err
+		}
+	}
+
+	for _, file := range item.Files {
+		if matcher != nil && !matcher.MatchName(file.Attributes.Name) {
+			continue
+		}
+
+		// Throw error if there are multiple files with the same label.
+		if _, found := secretData[file.Attributes.Name]; found {
+			return fmt.Errorf("found multiple labels with the same key '%s'", file.Attributes.Name)
+		}
+
+		contents, err := p.fetchFile(ctx, item.ID, file.FieldID, file.Attributes)
+		if err != nil {
+			return err
+		}
+		secretData[file.Attributes.Name] = contents
+	}
+
+	return nil
+}
+
 func countFieldsWithLabel(fieldLabel string, fields []onepassword.ItemField) int {
 	count := 0
 	for _, field := range fields {
@@ -271,6 +450,17 @@ func countFieldsWithLabel(fieldLabel string, fields []onepassword.ItemField) int
 	return count
 }
 
+func countFilesWithLabel(fileLabel string, files []onepassword.ItemFile) int {
+	count := 0
+	for _, file := range files {
+		if file.Attributes.Name == fileLabel {
+			count++
+		}
+	}
+
+	return count
+}
+
 // Clean property string by removing property prefix if needed.
 func getObjType(documentType onepassword.ItemCategory, property string) (string, string) {
 	if strings.HasPrefix(property, fieldPrefix+prefixSplitter) {
@@ -558,6 +748,29 @@ func (p *SecretsClient) GetVault(ctx context.Context, titleOrUUID string) (strin
 	return "", fmt.Errorf("vault %s not found", titleOrUUID)
 }
 
+// fetchItemByID retrieves an item by its ID, using the cache if possible.
+func (p *SecretsClient) fetchItemByID(ctx context.Context, id string) (onepassword.Item, error) {
+	cacheKey := itemCachePrefix + p.vaultID + ":" + id
+	if cached, ok := p.cacheGet(cacheKey); ok {
+		var item onepassword.Item
+		if err := json.Unmarshal(cached, &item); err == nil {
+			return item, nil
+		}
+	}
+
+	item, err := p.client.Items().Get(ctx, p.vaultID, id)
+	metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKItemsGet, err)
+	if err != nil {
+		return onepassword.Item{}, err
+	}
+
+	if serialized, err := json.Marshal(item); err == nil {
+		p.cacheAdd(cacheKey, serialized)
+	}
+	return item, nil
+}
+
+// findItem retrieves an item by its title or ID, using the cache if possible.
 func (p *SecretsClient) findItem(ctx context.Context, name string) (onepassword.Item, error) {
 	cacheKey := itemCachePrefix + p.vaultID + ":" + name
 	if cached, ok := p.cacheGet(cacheKey); ok {
@@ -570,9 +783,8 @@ func (p *SecretsClient) findItem(ctx context.Context, name string) (onepassword.
 	var item onepassword.Item
 	var err error
 
-	if isNativeItemID(name) {
-		item, err = p.client.Items().Get(ctx, p.vaultID, name)
-		metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKItemsGet, err)
+	if isNativeID(name) {
+		item, err = p.fetchItemByID(ctx, name)
 		if err != nil {
 			if isNotFoundError(err) {
 				return onepassword.Item{}, ErrKeyNotFound
@@ -580,12 +792,13 @@ func (p *SecretsClient) findItem(ctx context.Context, name string) (onepassword.
 			return onepassword.Item{}, err
 		}
 	} else {
-		items, err := p.client.Items().List(ctx, p.vaultID)
-		metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKItemsList, err)
+		// If name is not a native item ID, we have to list items and find the matching title.
+		items, err := p.listItems(ctx)
 		if err != nil {
 			return onepassword.Item{}, fmt.Errorf("failed to list items: %w", err)
 		}
 
+		// Find the ID of the item matching the given name. Throw an error if there are multiple items with the same name, or if no items are found.
 		var itemUUID string
 		for _, v := range items {
 			if v.Title == name {
@@ -595,20 +808,20 @@ func (p *SecretsClient) findItem(ctx context.Context, name string) (onepassword.
 				itemUUID = v.ID
 			}
 		}
-
 		if itemUUID == "" {
 			return onepassword.Item{}, ErrKeyNotFound
 		}
 
-		item, err = p.client.Items().Get(ctx, p.vaultID, itemUUID)
-		metrics.ObserveAPICall(constants.ProviderOnePasswordSDK, constants.CallOnePasswordSDKItemsGet, err)
+		// Fetch the item by ID to get all its details.
+		item, err = p.fetchItemByID(ctx, itemUUID)
 		if err != nil {
 			return onepassword.Item{}, err
 		}
-	}
 
-	if serialized, err := json.Marshal(item); err == nil {
-		p.cacheAdd(cacheKey, serialized)
+		// While fetchItemByID will cache the item by its ID, we also want to cache it by its name.
+		if serialized, err := json.Marshal(item); err == nil {
+			p.cacheAdd(cacheKey, serialized)
+		}
 	}
 
 	return item, nil

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

@@ -590,6 +590,7 @@ func TestGetVault(t *testing.T) {
 
 type fakeLister struct {
 	listAllResult    []onepassword.ItemOverview
+	listErr          error
 	createCalled     bool
 	createdFieldType onepassword.ItemFieldType
 	createdParams    onepassword.ItemCreateParams
@@ -597,6 +598,7 @@ type fakeLister struct {
 	putItem          onepassword.Item
 	deleteCalled     bool
 	getResult        onepassword.Item
+	getErr           error
 	fileLister       onepassword.ItemsFilesAPI
 }
 
@@ -610,7 +612,7 @@ func (f *fakeLister) Create(ctx context.Context, params onepassword.ItemCreatePa
 }
 
 func (f *fakeLister) Get(ctx context.Context, vaultID, itemID string) (onepassword.Item, error) {
-	return f.getResult, nil
+	return f.getResult, f.getErr
 }
 
 func (f *fakeLister) Put(ctx context.Context, item onepassword.Item) (onepassword.Item, error) {
@@ -629,7 +631,7 @@ func (f *fakeLister) Archive(ctx context.Context, vaultID, itemID string) error
 }
 
 func (f *fakeLister) List(ctx context.Context, vaultID string, opts ...onepassword.ItemListFilter) ([]onepassword.ItemOverview, error) {
-	return f.listAllResult, nil
+	return f.listAllResult, f.listErr
 }
 
 func (f *fakeLister) Shares() onepassword.ItemsSharesAPI {
@@ -1193,6 +1195,70 @@ func (f *fakeListerWithCounter) Create(ctx context.Context, item onepassword.Ite
 	return f.fakeLister.Create(ctx, item)
 }
 
+// fakeFileListerWithCounter wraps fakeFileLister and tracks Read call count.
+type fakeFileListerWithCounter struct {
+	*fakeFileLister
+	readCallCount int
+}
+
+func (f *fakeFileListerWithCounter) Attach(ctx context.Context, item onepassword.Item, fileParams onepassword.FileCreateParams) (onepassword.Item, error) {
+	return onepassword.Item{}, nil
+}
+
+func (f *fakeFileListerWithCounter) Read(ctx context.Context, vaultID, itemID string, attr onepassword.FileAttributes) ([]byte, error) {
+	f.readCallCount++
+	return f.readContent, nil
+}
+
+func (f *fakeFileListerWithCounter) Delete(ctx context.Context, item onepassword.Item, sectionID, fieldID string) (onepassword.Item, error) {
+	return onepassword.Item{}, nil
+}
+
+func (f *fakeFileListerWithCounter) ReplaceDocument(ctx context.Context, item onepassword.Item, docParams onepassword.DocumentCreateParams) (onepassword.Item, error) {
+	return onepassword.Item{}, nil
+}
+
+// statefulFakeListerWithCounter wraps statefulFakeLister and tracks Get call count.
+type statefulFakeListerWithCounter struct {
+	*statefulFakeLister
+	getCallCount  int
+	listCallCount int
+}
+
+func (f *statefulFakeListerWithCounter) Get(ctx context.Context, vaultID, itemID string) (onepassword.Item, error) {
+	f.getCallCount++
+	return f.statefulFakeLister.Get(ctx, vaultID, itemID)
+}
+
+func (f *statefulFakeListerWithCounter) Put(ctx context.Context, item onepassword.Item) (onepassword.Item, error) {
+	return f.statefulFakeLister.Put(ctx, item)
+}
+
+func (f *statefulFakeListerWithCounter) Delete(ctx context.Context, vaultID, itemID string) error {
+	return f.statefulFakeLister.Delete(ctx, vaultID, itemID)
+}
+
+func (f *statefulFakeListerWithCounter) Archive(ctx context.Context, vaultID, itemID string) error {
+	return f.statefulFakeLister.Archive(ctx, vaultID, itemID)
+}
+
+func (f *statefulFakeListerWithCounter) List(ctx context.Context, vaultID string, opts ...onepassword.ItemListFilter) ([]onepassword.ItemOverview, error) {
+	f.listCallCount++
+	return f.statefulFakeLister.List(ctx, vaultID, opts...)
+}
+
+func (f *statefulFakeListerWithCounter) Shares() onepassword.ItemsSharesAPI {
+	return f.statefulFakeLister.Shares()
+}
+
+func (f *statefulFakeListerWithCounter) Files() onepassword.ItemsFilesAPI {
+	return f.statefulFakeLister.Files()
+}
+
+func (f *statefulFakeListerWithCounter) Create(ctx context.Context, item onepassword.ItemCreateParams) (onepassword.Item, error) {
+	return f.statefulFakeLister.Create(ctx, item)
+}
+
 var _ onepassword.SecretsAPI = &fakeClient{}
 var _ onepassword.VaultsAPI = &fakeClient{}
 var _ onepassword.ItemsAPI = &fakeLister{}
@@ -1436,7 +1502,7 @@ func TestNormalizeItemFields(t *testing.T) {
 	assert.Equal(t, &realSection, got[2].SectionID, "non-empty SectionID should be unchanged")
 }
 
-func TestIsNativeItemID(t *testing.T) {
+func TestIsNativeID(t *testing.T) {
 	tests := []struct {
 		name     string
 		input    string
@@ -1455,14 +1521,481 @@ func TestIsNativeItemID(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got := isNativeItemID(tt.input)
+			got := isNativeID(tt.input)
 			if got != tt.expected {
-				t.Errorf("isNativeItemID(%q) = %v, want %v", tt.input, got, tt.expected)
+				t.Errorf("isNativeID(%q) = %v, want %v", tt.input, got, tt.expected)
+			}
+		})
+	}
+}
+
+func TestGetAllSecrets(t *testing.T) {
+	item1 := onepassword.Item{
+		ID:       "item-1",
+		Title:    "key1",
+		Category: "login",
+		VaultID:  "vault-id",
+		Tags:     []string{"tag1"},
+		Fields: []onepassword.ItemField{
+			{
+				ID:        "field-1",
+				Title:     "username",
+				FieldType: onepassword.ItemFieldTypeConcealed,
+				Value:     "testuser",
+			},
+			{
+				ID:        "field-2",
+				Title:     "password",
+				FieldType: onepassword.ItemFieldTypeConcealed,
+				Value:     "testpass",
+			},
+		},
+	}
+	item2 := onepassword.Item{
+		ID:       "item-2",
+		Title:    "key2",
+		Category: "login",
+		VaultID:  "vault-id",
+		Tags:     []string{"tag2"},
+		Fields: []onepassword.ItemField{
+			{
+				ID:        "field-3",
+				Title:     "db-host",
+				FieldType: onepassword.ItemFieldTypeConcealed,
+				Value:     "testdb",
+			},
+		},
+	}
+	item3 := onepassword.Item{
+		ID:       "item-3",
+		Title:    "file1",
+		Category: "login",
+		VaultID:  "vault-id",
+		Tags:     []string{"tag1"},
+		Files: []onepassword.ItemFile{
+			{
+				Attributes: onepassword.FileAttributes{
+					Name: "certfile",
+					ID:   "file-id",
+				},
+				FieldID: "field-4",
+			},
+		},
+	}
+
+	createLister := func(items ...onepassword.Item) *statefulFakeLister {
+		fl := &statefulFakeLister{
+			items: make(map[string]onepassword.Item),
+			fileLister: &fakeFileLister{
+				readContent: []byte("content"),
+			},
+		}
+		for _, it := range items {
+			fl.items[it.ID] = it
+			fl.listAllResult = append(fl.listAllResult, onepassword.ItemOverview{
+				ID:      it.ID,
+				Title:   it.Title,
+				VaultID: it.VaultID,
+				Tags:    it.Tags,
+			})
+		}
+		return fl
+	}
+
+	tests := []struct {
+		name        string
+		ref         v1.ExternalSecretFind
+		want        map[string][]byte
+		assertError func(t *testing.T, err error)
+		client      func() *onepassword.Client
+	}{
+		{
+			name: "returns all fields and files from all items",
+			client: func() *onepassword.Client {
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item1, item2, item3),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.NoError(t, err)
+			},
+			ref: v1.ExternalSecretFind{},
+			want: map[string][]byte{
+				"username": []byte("testuser"),
+				"password": []byte("testpass"),
+				"db-host":  []byte("testdb"),
+				"certfile": []byte("content"),
+			},
+		},
+		{
+			name: "filters items by path",
+			client: func() *onepassword.Client {
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item1, item2, item3),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.NoError(t, err)
+			},
+			ref: v1.ExternalSecretFind{
+				Path: &item1.Title,
+			},
+			want: map[string][]byte{
+				"username": []byte("testuser"),
+				"password": []byte("testpass"),
+			},
+		},
+		{
+			name: "filters items by tag",
+			client: func() *onepassword.Client {
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item1, item2, item3),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.NoError(t, err)
+			},
+			ref: v1.ExternalSecretFind{
+				Tags: map[string]string{"tag1": "true"},
+			},
+			want: map[string][]byte{
+				"username": []byte("testuser"),
+				"password": []byte("testpass"),
+				"certfile": []byte("content"),
+			},
+		},
+		{
+			name: "filters fields by name regex",
+			client: func() *onepassword.Client {
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item1, item2, item3),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.NoError(t, err)
+			},
+			ref: v1.ExternalSecretFind{
+				Name: &v1.FindName{RegExp: "e"},
+			},
+			want: map[string][]byte{
+				"username": []byte("testuser"),
+				"certfile": []byte("content"),
+			},
+		},
+		{
+			name: "returns error on duplicate field name",
+			client: func() *onepassword.Client {
+				item2dup := onepassword.Item{
+					ID:       "item-2-dup",
+					Title:    "key2-dup",
+					Category: "login",
+					VaultID:  "vault-id",
+					Tags:     []string{"tag2"},
+					Fields: []onepassword.ItemField{
+						{
+							ID:        "field-3-dup",
+							Title:     "db-host",
+							FieldType: onepassword.ItemFieldTypeConcealed,
+							Value:     "testdb-dup",
+						},
+					},
+				}
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item2, item2dup),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.ErrorContains(t, err, "found multiple labels with the same key")
+			},
+			ref: v1.ExternalSecretFind{},
+		},
+		{
+			name: "returns error on duplicate file name",
+			client: func() *onepassword.Client {
+				item3dup := onepassword.Item{
+					ID:       "item-3",
+					Title:    "file1-dup",
+					Category: "login",
+					VaultID:  "vault-id",
+					Tags:     []string{"tag1"},
+					Files: []onepassword.ItemFile{
+						{
+							Attributes: onepassword.FileAttributes{
+								Name: "certfile",
+								ID:   "file-id-dup",
+							},
+							FieldID: "field-4-dup",
+						},
+					},
+				}
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item3, item3dup),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.ErrorContains(t, err, "found multiple labels with the same key")
+			},
+			ref: v1.ExternalSecretFind{},
+		},
+		{
+			name: "returns error if field name matches file name",
+			client: func() *onepassword.Client {
+				item2filedup := onepassword.Item{
+					ID:       "item-2-dup",
+					Title:    "key2-dup",
+					Category: "login",
+					VaultID:  "vault-id",
+					Tags:     []string{"tag1"},
+					Files: []onepassword.ItemFile{
+						{
+							Attributes: onepassword.FileAttributes{
+								Name: "db-host",
+								ID:   "file-id-dup",
+							},
+							FieldID: "field-3-dup",
+						},
+					},
+				}
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   createLister(item2, item2filedup),
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.ErrorContains(t, err, "found multiple labels with the same key")
+			},
+			ref: v1.ExternalSecretFind{},
+		},
+		{
+			name: "returns error when list fails",
+			client: func() *onepassword.Client {
+				fl := &fakeLister{listAllResult: nil}
+				fl.listErr = errors.New("list error")
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   fl,
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.ErrorContains(t, err, "list error")
+			},
+			ref: v1.ExternalSecretFind{},
+		},
+		{
+			name: "returns error when get fails",
+			client: func() *onepassword.Client {
+				fl := &fakeLister{
+					listAllResult: []onepassword.ItemOverview{{ID: "item-1", Title: "app-secrets", VaultID: "vault-id"}},
+					getErr:        errors.New("get error"),
+				}
+				return &onepassword.Client{
+					SecretsAPI: &fakeClient{},
+					VaultsAPI:  &fakeClient{},
+					ItemsAPI:   fl,
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.ErrorContains(t, err, "get error")
+			},
+			ref: v1.ExternalSecretFind{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			p := &SecretsClient{
+				client:      tt.client(),
+				vaultPrefix: "op://vault/",
 			}
+			got, err := p.GetAllSecrets(t.Context(), tt.ref)
+			tt.assertError(t, err)
+			require.Equal(t, tt.want, got)
 		})
 	}
 }
 
+func TestCachingGetAllSecrets(t *testing.T) {
+	item1 := onepassword.Item{
+		ID:       "item1aaaaaaaaaaaaaaaaaaaaa",
+		Title:    "key1",
+		Category: "login",
+		VaultID:  "vault-id",
+		Tags:     []string{"tag1"},
+		Fields: []onepassword.ItemField{
+			{
+				ID:        "field1aaaaaaaaaaaaaaaaaaaa",
+				Title:     "username",
+				FieldType: onepassword.ItemFieldTypeConcealed,
+				Value:     "testuser",
+			},
+			{
+				ID:        "field2aaaaaaaaaaaaaaaaaaaa",
+				Title:     "password",
+				FieldType: onepassword.ItemFieldTypeConcealed,
+				Value:     "testpass",
+			},
+		},
+	}
+	item2 := onepassword.Item{
+		ID:       "item2bbbbbbbbbbbbbbbbbbbbb",
+		Title:    "file1",
+		Category: "login",
+		VaultID:  "vault-id",
+		Tags:     []string{"tag2"},
+		Files: []onepassword.ItemFile{
+			{
+				Attributes: onepassword.FileAttributes{
+					Name: "certfile",
+					ID:   "item2filebbbbbbbbbbbbbbbbb",
+				},
+				FieldID: "field3bbbbbbbbbbbbbbbbbbbb",
+			},
+		},
+	}
+
+	createLister := func(items ...onepassword.Item) *statefulFakeListerWithCounter {
+		fl := &statefulFakeListerWithCounter{
+			statefulFakeLister: &statefulFakeLister{
+				items: make(map[string]onepassword.Item),
+				fileLister: &fakeFileListerWithCounter{
+					fakeFileLister: &fakeFileLister{
+						readContent: []byte("content"),
+					},
+				},
+			},
+		}
+		for _, it := range items {
+			fl.items[it.ID] = it
+			fl.listAllResult = append(fl.listAllResult, onepassword.ItemOverview{
+				ID:      it.ID,
+				Title:   it.Title,
+				VaultID: it.VaultID,
+				Tags:    it.Tags,
+			})
+		}
+		return fl
+	}
+
+	newCachedClient := func(fl *statefulFakeListerWithCounter) *SecretsClient {
+		fc := &fakeClientWithCounter{
+			fakeClient: &fakeClient{
+				listAllResult: []onepassword.VaultOverview{{ID: "vault-id", Title: "vault"}},
+				resolveResult: "testpass",
+			},
+		}
+		return &SecretsClient{
+			client:  &onepassword.Client{SecretsAPI: fc, VaultsAPI: fc, ItemsAPI: fl},
+			vaultID: "vault-id",
+			cache:   expirable.NewLRU[string, []byte](100, nil, time.Minute),
+		}
+	}
+
+	t.Run("second GetAllSecrets call does not re-fetch item list from API", func(t *testing.T) {
+		fl := createLister(item1, item2)
+		p := newCachedClient(fl)
+		find := v1.ExternalSecretFind{}
+
+		_, err := p.GetAllSecrets(t.Context(), find)
+		require.NoError(t, err)
+		// There can be quite a few list calls depending on the items, so we'll just make sure
+		// at least one was made, and that the count doesn't increase on the second call.
+		assert.NotZero(t, fl.listCallCount, "first call should list from API")
+
+		previousCallCount := fl.listCallCount
+		_, err = p.GetAllSecrets(t.Context(), find)
+		require.NoError(t, err)
+		assert.Equal(t, previousCallCount, fl.listCallCount, "second call should use item cache, not re-fetch list from API")
+	})
+
+	t.Run("second GetAllSecrets call does not re-fetch items from API", func(t *testing.T) {
+		fl := createLister(item1)
+		p := newCachedClient(fl)
+		find := v1.ExternalSecretFind{}
+
+		_, err := p.GetAllSecrets(t.Context(), find)
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "first call should fetch from API")
+
+		_, err = p.GetAllSecrets(t.Context(), find)
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "second call should use item cache, not re-fetch")
+	})
+
+	t.Run("second GetAllSecrets call does not re-read files from API", func(t *testing.T) {
+		fl := createLister(item2)
+		ffl := fl.fileLister.(*fakeFileListerWithCounter)
+		p := newCachedClient(fl)
+		find := v1.ExternalSecretFind{}
+
+		_, err := p.GetAllSecrets(t.Context(), find)
+		require.NoError(t, err)
+		assert.Equal(t, 1, ffl.readCallCount, "first call should read file from API")
+
+		_, err = p.GetAllSecrets(t.Context(), find)
+		require.NoError(t, err)
+		assert.Equal(t, 1, ffl.readCallCount, "second call should use file cache, not re-read")
+	})
+
+	t.Run("item fetched by GetAllSecrets is reused by GetSecretMap", func(t *testing.T) {
+		fl := createLister(item1)
+		p := newCachedClient(fl)
+
+		want := map[string][]byte{"username": []byte("testuser"), "password": []byte("testpass")}
+		got, err := p.GetAllSecrets(t.Context(), v1.ExternalSecretFind{})
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "GetAllSecrets fetches item once")
+		assert.Equal(t, want, got, "GetAllSecrets should return expected secrets")
+
+		got, err = p.GetSecretMap(t.Context(), v1.ExternalSecretDataRemoteRef{Key: item1.Title})
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "GetSecretMap should use cached item from GetAllSecrets")
+		assert.Equal(t, want, got, "GetSecretMap should return expected secrets")
+
+		want = map[string][]byte{"username": []byte("testuser")}
+		got, err = p.GetSecretMap(t.Context(), v1.ExternalSecretDataRemoteRef{Key: item1.Title, Property: "username"})
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "GetSecretMap with Property should use cached item from GetAllSecrets")
+		assert.Equal(t, want, got, "GetSecretMap with Property should return expected secrets")
+	})
+
+	t.Run("item fetched by GetAllSecrets is reused by GetSecret", func(t *testing.T) {
+		t.Skip("TODO: GetSecret and GetSecretMap/GetAllSecrets use different caching formats. " +
+			"See https://github.com/external-secrets/external-secrets/issues/6444")
+
+		fl := createLister(item1)
+		p := newCachedClient(fl)
+		fc := p.client.SecretsAPI.(*fakeClientWithCounter)
+
+		wantAllSecrets := map[string][]byte{"username": []byte("testuser"), "password": []byte("testpass")}
+		gotAllSecrets, err := p.GetAllSecrets(t.Context(), v1.ExternalSecretFind{})
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "GetAllSecrets fetches item once")
+		assert.Equal(t, 0, fc.resolveCallCount, "GetAllSecrets should not call Resolve")
+		assert.Equal(t, wantAllSecrets, gotAllSecrets, "GetAllSecrets should return expected secrets")
+
+		wantSecret := []byte("testpass")
+		gotSecret, err := p.GetSecret(t.Context(), v1.ExternalSecretDataRemoteRef{Key: fmt.Sprintf("%s/password", item1.Title)})
+		require.NoError(t, err)
+		assert.Equal(t, 1, fl.getCallCount, "GetSecret should use cached item from GetAllSecrets")
+		assert.Equal(t, 0, fc.resolveCallCount, "GetSecret should not call Resolve due to cached value")
+		assert.Equal(t, wantSecret, gotSecret, "GetSecret should return expected secrets")
+	})
+}
+
 func TestPushAllKeys(t *testing.T) {
 	const (
 		testExistingItem = "existing-item"