Repository: external-secrets/external-secrets (Go)

Issue:
Support multiple fields in `onepasswordsdk`
**Is your feature request related to a problem? Please describe.**
The legacy onepassword-connect provider supported fetching multiple fields of a single item, via `dataFrom.extract` for example. The new `onepasswordsdk` provider, introduced in #4628, does not support this.

**Describe the solution you'd like**
Add support for fetching multiple or all fields of a vault item.

**Describe alternatives you've considered**
Fetch each vault item field explicitly and update this list whenever a field is added or removed. High maintenance cost.



Discussion:
hi @jfroy ! are you able to contribute this feature ?  😄 
> hi [@jfroy](https://github.com/jfroy) ! are you able to contribute this feature ? 😄

No, not in the near future. I have a demanding job and a newborn on my plate at the moment.
>  No, not in the near future. I have a demanding job and a newborn on my plate at the moment.

Just like all of the volunteers that maintain this project, ironically 😄

Hopefully someone from the community will pick this up.  
@jfroy you're lucky this wasn't particularly difficult. :) Also, I agree that it's nice that there is feature parity with 1password connect. Eventually, it would be nice to completely remove that provider and use the SDK instead. :) 
> [@jfroy](https://github.com/jfroy) you're lucky this wasn't particularly difficult. :) Also, I agree that it's nice that there is feature parity with 1password connect. Eventually, it would be nice to completely remove that provider and use the SDK instead. :)

Doing awesome work as always @Skarlso 🥳 
> Eventually, it would be nice to completely remove that provider and use the SDK instead. :)

The API limits imposed by 1password say otherwise 😬
Heh, hard truth right there. Hopefully, v2 with secret caching might help just as connect did.
It would help in some cases but as it stands now I would hit the daily limits from applying all my externalsecrets. I think 1password should increase them for it to be of any use.



Patch:
diff --git a/pkg/provider/onepasswordsdk/client.go b/pkg/provider/onepasswordsdk/client.go
index beb4e2801fe..0a6d4397235 100644
--- a/pkg/provider/onepasswordsdk/client.go
+++ b/pkg/provider/onepasswordsdk/client.go
@@ -16,7 +16,6 @@ package onepasswordsdk
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"strings"
@@ -29,6 +28,13 @@ import (
 	"github.com/external-secrets/external-secrets/pkg/utils/metadata"
 )
 
+const (
+	fieldPrefix             = "field"
+	filePrefix              = "file"
+	prefixSplitter          = "/"
+	errExpectedOneFieldMsgF = "found more than 1 fields with title '%s' in '%s', got %d"
+)
+
 // ErrKeyNotFound is returned when a key is not found in the 1Password Vaults.
 var ErrKeyNotFound = errors.New("key not found")
 
@@ -112,34 +118,87 @@ func (p *Provider) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (
 	return nil, fmt.Errorf(errOnePasswordSdkStore, errors.New(errNotImplemented))
 }
 
-// GetSecretMap implements v1.SecretsClient.
+// GetSecretMap returns multiple k/v pairs from the provider, for dataFrom.extract.
 func (p *Provider) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
 	if ref.Version != "" {
 		return nil, errors.New(errVersionNotImplemented)
 	}
 
-	// Gets a secret as normal, expecting secret value to be a json object
-	data, err := p.GetSecret(ctx, ref)
+	item, err := p.findItem(ctx, ref.Key)
 	if err != nil {
-		return nil, fmt.Errorf("error getting secret %s: %w", ref.Key, err)
+		return nil, err
 	}
 
-	// Maps the json data to a string:string map
-	kv := make(map[string]string)
-	err = json.Unmarshal(data, &kv)
-	if err != nil {
-		return nil, fmt.Errorf("failed to unmarshal data: %w", err)
+	propertyType, property := getObjType(item.Category, ref.Property)
+	if propertyType == filePrefix {
+		return p.getFiles(ctx, item, property)
 	}
 
-	// Converts values in K:V pairs into bytes, while leaving keys as strings
+	return p.getFields(item, property)
+}
+
+func (p *Provider) getFields(item onepassword.Item, property string) (map[string][]byte, error) {
 	secretData := make(map[string][]byte)
-	for k, v := range kv {
-		secretData[k] = []byte(v)
+	for _, field := range item.Fields {
+		if property != "" && field.Title != property {
+			continue
+		}
+		if length := countFieldsWithLabel(field.Title, item.Fields); length != 1 {
+			return nil, fmt.Errorf(errExpectedOneFieldMsgF, field.Title, item.Title, length)
+		}
+
+		// caution: do not use client.GetValue here because it has undesirable behavior on keys with a dot in them
+		secretData[field.Title] = []byte(field.Value)
 	}
 
 	return secretData, nil
 }
 
+func (p *Provider) getFiles(ctx context.Context, item onepassword.Item, property string) (map[string][]byte, error) {
+	secretData := make(map[string][]byte)
+	for _, file := range item.Files {
+		if property != "" && file.Attributes.Name != property {
+			continue
+		}
+
+		contents, err := p.client.Items().Files().Read(ctx, p.vaultID, file.FieldID, file.Attributes)
+		if err != nil {
+			return nil, fmt.Errorf("failed to read file: %w", err)
+		}
+
+		secretData[file.Attributes.Name] = contents
+	}
+
+	return secretData, nil
+}
+
+func countFieldsWithLabel(fieldLabel string, fields []onepassword.ItemField) int {
+	count := 0
+	for _, field := range fields {
+		if field.Title == fieldLabel {
+			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) {
+		return fieldPrefix, property[6:]
+	}
+	if strings.HasPrefix(property, filePrefix+prefixSplitter) {
+		return filePrefix, property[5:]
+	}
+
+	if documentType == onepassword.ItemCategoryDocument {
+		return filePrefix, property
+	}
+
+	return fieldPrefix, property
+}
+
 // 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


Test patch:
diff --git a/pkg/provider/onepasswordsdk/client_test.go b/pkg/provider/onepasswordsdk/client_test.go
index 2a2b7565350..4f1441f467b 100644
--- a/pkg/provider/onepasswordsdk/client_test.go
+++ b/pkg/provider/onepasswordsdk/client_test.go
@@ -116,13 +116,40 @@ func TestProviderGetSecretMap(t *testing.T) {
 		client      func() *onepassword.Client
 	}{
 		{
-			name: "get secret successfully",
+			name: "get secret successfully for files",
 			client: func() *onepassword.Client {
-				fc := &fakeClient{
-					resolveResult: `{"key": "value"}`,
+				fc := &fakeClient{}
+				fl := &fakeLister{
+					listAllResult: []onepassword.ItemOverview{
+						{
+							ID:       "test-item-id",
+							Title:    "key",
+							Category: "login",
+							VaultID:  "vault-id",
+						},
+					},
+					getResult: onepassword.Item{
+						ID:       "test-item-id",
+						Title:    "key",
+						Category: "login",
+						VaultID:  "vault-id",
+						Files: []onepassword.ItemFile{
+							{
+								Attributes: onepassword.FileAttributes{
+									Name: "name",
+									ID:   "id",
+								},
+								FieldID: "field-id",
+							},
+						},
+					},
+					fileLister: &fakeFileLister{
+						readContent: []byte("content"),
+					},
 				}
 				return &onepassword.Client{
 					SecretsAPI: fc,
+					ItemsAPI:   fl,
 					VaultsAPI:  fc,
 				}
 			},
@@ -130,10 +157,110 @@ func TestProviderGetSecretMap(t *testing.T) {
 				require.NoError(t, err)
 			},
 			ref: v1.ExternalSecretDataRemoteRef{
-				Key: "secret",
+				Key:      "key",
+				Property: "file/name",
+			},
+			want: map[string][]byte{
+				"name": []byte("content"),
+			},
+		},
+		{
+			name: "get secret successfully for fields",
+			client: func() *onepassword.Client {
+				fc := &fakeClient{}
+				fl := &fakeLister{
+					listAllResult: []onepassword.ItemOverview{
+						{
+							ID:       "test-item-id",
+							Title:    "key",
+							Category: "login",
+							VaultID:  "vault-id",
+						},
+					},
+					getResult: onepassword.Item{
+						ID:       "test-item-id",
+						Title:    "key",
+						Category: "login",
+						VaultID:  "vault-id",
+						Fields: []onepassword.ItemField{
+							{
+								ID:        "field-id",
+								Title:     "name",
+								FieldType: onepassword.ItemFieldTypeConcealed,
+								Value:     "value",
+							},
+						},
+					},
+					fileLister: &fakeFileLister{
+						readContent: []byte("content"),
+					},
+				}
+				return &onepassword.Client{
+					SecretsAPI: fc,
+					ItemsAPI:   fl,
+					VaultsAPI:  fc,
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.NoError(t, err)
+			},
+			ref: v1.ExternalSecretDataRemoteRef{
+				Key:      "key",
+				Property: "field/name",
 			},
 			want: map[string][]byte{
-				"key": []byte("value"),
+				"name": []byte("value"),
+			},
+		},
+		{
+			name: "get secret fails with fields with same title",
+			client: func() *onepassword.Client {
+				fc := &fakeClient{}
+				fl := &fakeLister{
+					listAllResult: []onepassword.ItemOverview{
+						{
+							ID:       "test-item-id",
+							Title:    "key",
+							Category: "login",
+							VaultID:  "vault-id",
+						},
+					},
+					getResult: onepassword.Item{
+						ID:       "test-item-id",
+						Title:    "key",
+						Category: "login",
+						VaultID:  "vault-id",
+						Fields: []onepassword.ItemField{
+							{
+								ID:        "field-id",
+								Title:     "name",
+								FieldType: onepassword.ItemFieldTypeConcealed,
+								Value:     "value",
+							},
+							{
+								ID:        "field-id",
+								Title:     "name",
+								FieldType: onepassword.ItemFieldTypeConcealed,
+								Value:     "value",
+							},
+						},
+					},
+					fileLister: &fakeFileLister{
+						readContent: []byte("content"),
+					},
+				}
+				return &onepassword.Client{
+					SecretsAPI: fc,
+					ItemsAPI:   fl,
+					VaultsAPI:  fc,
+				}
+			},
+			assertError: func(t *testing.T, err error) {
+				require.ErrorContains(t, err, "found more than 1 fields with title 'name' in 'key', got 2")
+			},
+			ref: v1.ExternalSecretDataRemoteRef{
+				Key:      "key",
+				Property: "field/name",
 			},
 		},
 	}
@@ -145,7 +272,7 @@ func TestProviderGetSecretMap(t *testing.T) {
 			}
 			got, err := p.GetSecretMap(context.Background(), tt.ref)
 			tt.assertError(t, err)
-			require.Equal(t, got, tt.want)
+			require.Equal(t, tt.want, got)
 		})
 	}
 }
@@ -471,6 +598,7 @@ type fakeLister struct {
 	putCalled     bool
 	deleteCalled  bool
 	getResult     onepassword.Item
+	fileLister    onepassword.ItemsFilesAPI
 }
 
 func (f *fakeLister) Create(ctx context.Context, params onepassword.ItemCreateParams) (onepassword.Item, error) {
@@ -505,9 +633,31 @@ func (f *fakeLister) Shares() onepassword.ItemsSharesAPI {
 }
 
 func (f *fakeLister) Files() onepassword.ItemsFilesAPI {
-	return nil
+	return f.fileLister
 }
 
+type fakeFileLister struct {
+	readContent []byte
+}
+
+func (f *fakeFileLister) Attach(ctx context.Context, item onepassword.Item, fileParams onepassword.FileCreateParams) (onepassword.Item, error) {
+	return onepassword.Item{}, nil
+}
+
+func (f *fakeFileLister) Read(ctx context.Context, vaultID, itemID string, attr onepassword.FileAttributes) ([]byte, error) {
+	return f.readContent, nil
+}
+
+func (f *fakeFileLister) Delete(ctx context.Context, item onepassword.Item, sectionID, fieldID string) (onepassword.Item, error) {
+	return onepassword.Item{}, nil
+}
+
+func (f *fakeFileLister) ReplaceDocument(ctx context.Context, item onepassword.Item, docParams onepassword.DocumentCreateParams) (onepassword.Item, error) {
+	return onepassword.Item{}, nil
+}
+
+var _ onepassword.ItemsFilesAPI = (*fakeFileLister)(nil)
+
 type fakeClient struct {
 	resolveResult   string
 	resolveError    error