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

feat(passbolt): add support for custom fields (#6696)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Signed-off-by: Sander Knopper <s.knopper@flusso.nl>
Sander Knopper 2 недель назад
Родитель
Сommit
6f6fa86f95

+ 29 - 0
docs/provider/passbolt.md

@@ -52,3 +52,32 @@ Instead of retrieving secrets by ID you can also use `dataFrom` to search for se
 ```yaml
 {% include 'passbolt-external-secret-findbyname.yaml' %}
 ```
+
+
+## Custom fields
+
+Passbolt resources can carry arbitrary custom fields beyond the standard
+`name`, `username`, `password`, `uri`, and `description` properties.
+ESO surfaces each custom field through the `custom_fields.<name>` property
+syntax, where `<name>` is the field's display name (its **metadata key**) as
+configured in Passbolt.
+
+```yaml
+{% include 'passbolt-external-secret-custom-fields.yaml' %}
+```
+
+The above external secret produces a Kubernetes Secret in the following form:
+
+```yaml
+{% include 'passbolt-secret-custom-fields-example.yaml' %}
+```
+
+When no `property` is specified, the full secret is returned as a JSON object.
+The `custom_fields` key is included in that object whenever the resource has
+at least one custom field with an unencrypted metadata key.
+
+!!! note
+    Custom fields whose **name** is also encrypted (Passbolt calls this a
+    *secret key* field) cannot be referenced by a display name because ESO
+    never sees the plaintext key. Those fields are omitted from both the
+    targeted `custom_fields.<name>` lookup and the full-JSON output.

+ 28 - 0
docs/snippets/passbolt-external-secret-custom-fields.yaml

@@ -0,0 +1,28 @@
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: passbolt-custom-fields-example
+spec:
+  refreshInterval: "1h0m0s"
+  secretStoreRef:
+    name: passbolt
+    kind: SecretStore
+  target:
+    name: passbolt-custom-fields
+  data:
+    # Fetch a single custom field by its display name (metadata_key).
+    # The property value is the literal prefix "custom_fields." followed by
+    # the name of the field as configured in Passbolt.
+  - secretKey: api_token
+    remoteRef:
+      key: e22487a8-feb8-4591-95aa-14b193930cb4 # Replace with the ID of an existing Passbolt secret
+      property: custom_fields.api-token
+  - secretKey: deploy_key
+    remoteRef:
+      key: e22487a8-feb8-4591-95aa-14b193930cb4
+      property: custom_fields.deploy-key
+    # Omitting property returns the full secret as JSON, with custom_fields
+    # included as a nested object keyed by the field display name.
+  - secretKey: full_secret
+    remoteRef:
+      key: e22487a8-feb8-4591-95aa-14b193930cb4

+ 9 - 0
docs/snippets/passbolt-secret-custom-fields-example.yaml

@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: passbolt-custom-fields
+data:
+  api_token: my-api-token-value
+  deploy_key: ssh-ed25519-AAAA...
+  full_secret: '{"name":"my-service","username":"deploy","password":"supersecretpassword","uri":"https://example.com","description":"","custom_fields":{"api-token":"my-api-token-value","deploy-key":"ssh-ed25519-AAAA..."}}'
+type: Opaque

+ 163 - 9
providers/v1/passbolt/passbolt.go

@@ -27,10 +27,13 @@ import (
 	"net/http"
 	"net/url"
 	"regexp"
+	"strconv"
+	"strings"
 
 	"github.com/passbolt/go-passbolt/api"
 	"github.com/passbolt/go-passbolt/helper"
 	corev1 "k8s.io/api/core/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
 	kclient "sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
@@ -39,7 +42,13 @@ import (
 	"github.com/external-secrets/external-secrets/runtime/esutils/resolvers"
 )
 
+var log = ctrl.Log.WithName("provider").WithName("passbolt")
+
+var errPassboltCustomFieldNotFound = errors.New("custom field not found")
+
 const (
+	customFieldPrefix = "custom_fields."
+
 	errPassboltStoreMissingProvider                = "missing: spec.provider.passbolt"
 	errPassboltStoreMissingAuth                    = "missing: spec.provider.passbolt.auth"
 	errPassboltStoreMissingAuthPassword            = "missing: spec.provider.passbolt.auth.passwordSecretRef"
@@ -47,7 +56,7 @@ const (
 	errPassboltStoreMissingHost                    = "missing: spec.provider.passbolt.host"
 	errPassboltExternalSecretMissingFindNameRegExp = "missing: find.name.regexp"
 	errPassboltStoreHostSchemeNotHTTPS             = "host Url has to be https scheme"
-	errPassboltSecretPropertyInvalid               = "property must be one of name, username, uri, password or description"
+	errPassboltSecretPropertyInvalid               = "property must be one of name, username, uri, password, description, or " + customFieldPrefix + "<name>"
 	errPassboltCAInvalid                           = "failed to parse CA certificate for Passbolt provider"
 	errPassboltUnexpectedTransport                 = "unexpected default http transport type"
 	errNotImplemented                              = "not implemented"
@@ -107,7 +116,7 @@ func (provider *ProviderPassbolt) NewClient(ctx context.Context, store esv1.Gene
 	// Prefetch caches for V5 metadata decryption performance (CLI pattern)
 	// This caches session keys and metadata keys for fast V5 decryption
 	if _, _, err := client.PreFetchCaches(ctx); err != nil {
-		fmt.Printf("passbolt: prefetch caches failed (non-fatal): %v\n", err)
+		log.V(1).Info("prefetch caches failed (non-fatal)", "error", err)
 	}
 
 	provider.client = client
@@ -184,7 +193,7 @@ func (provider *ProviderPassbolt) GetAllSecrets(ctx context.Context, ref esv1.Ex
 	// even if they don't match the filter, which may impact performance with large
 	// secret stores.
 	for _, resource := range resources {
-		secret, err := provider.getPassboltSecret(ctx, resource.ID)
+		secret, err := provider.secretFromResource(ctx, &resource)
 		if err != nil {
 			return nil, err
 		}
@@ -253,9 +262,17 @@ type Secret struct {
 	Password    string `json:"password"`
 	URI         string `json:"uri"`
 	Description string `json:"description"`
+	// CustomFields holds any custom fields defined on the resource, keyed by
+	// the field's metadata_key (display name). Fields whose name is also
+	// encrypted (secret_key) cannot be referenced by name and are omitted.
+	CustomFields map[string]string `json:"custom_fields,omitempty"`
 }
 
 // GetProp retrieves a specific property from the Passbolt secret.
+//
+// Supported properties: name, username, uri, password, description.
+// Custom fields are accessed via the "custom_fields.<name>" prefix, where
+// <name> is the field's metadata_key (display name) as configured in Passbolt.
 func (ps Secret) GetProp(key string) ([]byte, error) {
 	switch key {
 	case "name":
@@ -269,24 +286,161 @@ func (ps Secret) GetProp(key string) ([]byte, error) {
 	case "description":
 		return []byte(ps.Description), nil
 	default:
+		if fieldName, ok := strings.CutPrefix(key, customFieldPrefix); ok {
+			val, exists := ps.CustomFields[fieldName]
+			if !exists {
+				return nil, fmt.Errorf("%w: %s", errPassboltCustomFieldNotFound, fieldName)
+			}
+			return []byte(val), nil
+		}
 		return nil, errors.New(errPassboltSecretPropertyInvalid)
 	}
 }
 
 func (provider *ProviderPassbolt) getPassboltSecret(ctx context.Context, id string) (*Secret, error) {
-	_, name, username, uri, password, description, err := helper.GetResource(ctx, provider.client, id)
+	resource, err := provider.client.GetResource(ctx, id)
+	if err != nil {
+		return nil, err
+	}
+	return provider.secretFromResource(ctx, resource)
+}
+
+// secretFromResource decrypts an already-fetched resource into a Secret,
+// sparing callers that hold the resource a redundant GetResource call.
+func (provider *ProviderPassbolt) secretFromResource(ctx context.Context, resource *api.Resource) (*Secret, error) {
+	rType, err := provider.client.GetResourceType(ctx, resource.ResourceTypeID)
+	if err != nil {
+		return nil, err
+	}
+
+	secretData, err := provider.client.GetSecret(ctx, resource.ID)
 	if err != nil {
 		return nil, err
 	}
+
+	_, metaFields, secretFields, err := helper.GetResourceFieldMaps(provider.client, *resource, *secretData, *rType, true)
+	if err != nil {
+		return nil, err
+	}
+
 	return &Secret{
-		Name:        name,
-		Username:    username,
-		URI:         uri,
-		Password:    password,
-		Description: description,
+		Name:         helper.GetStringField(metaFields, "name"),
+		Username:     helper.GetStringField(metaFields, "username"),
+		URI:          helper.GetStringField(metaFields, "uri"),
+		Password:     helper.GetStringField(secretFields, "password"),
+		Description:  helper.GetStringField(metaFields, "description"),
+		CustomFields: buildCustomFields(metaFields, secretFields),
 	}, nil
 }
 
+// buildCustomFields extracts custom fields from the decrypted metadata and secret
+// field maps returned by helper.GetResourceFieldMaps. A custom field is always a
+// metadata + secret pair sharing an id: the name comes from metadata_key and the
+// value from secret_value (encrypted) or metadata_value (cleartext), whichever
+// side carries it.
+//
+// Fields whose name is encrypted (secret_key, leaving metadata_key empty) are
+// skipped because they cannot be referenced by a stable, user-visible name.
+//
+// Returns nil when the resource has no custom fields.
+func buildCustomFields(metaFields, secretFields map[string]any) map[string]string {
+	metaCF, ok := extractCustomFields(metaFields)
+	if !ok {
+		return nil
+	}
+	secretCF, _ := extractCustomFields(secretFields)
+
+	secretByID := make(map[string]map[string]any, len(secretCF))
+	for _, cf := range secretCF {
+		if id, ok := cf["id"].(string); ok && id != "" {
+			secretByID[id] = cf
+		}
+	}
+
+	result := make(map[string]string, len(metaCF))
+	for _, meta := range metaCF {
+		// A field without metadata_key stores its name encrypted (secret_key)
+		// and cannot be referenced by a stable, user-visible name.
+		if !hasNonEmptyString(meta, "metadata_key") {
+			continue
+		}
+		name, _ := meta["metadata_key"].(string)
+		id, _ := meta["id"].(string)
+
+		// secret_value (encrypted) takes precedence; otherwise the value is
+		// stored in cleartext as metadata_value.
+		if raw, ok := secretByID[id]["secret_value"]; ok {
+			result[name] = stringifyCustomFieldValue(raw)
+		} else {
+			result[name] = stringifyCustomFieldValue(meta["metadata_value"])
+		}
+	}
+
+	if len(result) == 0 {
+		return nil
+	}
+	return result
+}
+
+// extractCustomFields is copied verbatim from go-passbolt's helper package,
+// where it is currently unexported. Replace this with the library function once
+// a release exports it.
+//
+// extractCustomFields extracts the custom_fields array from a field map.
+func extractCustomFields(fields map[string]any) ([]map[string]any, bool) {
+	raw, ok := fields["custom_fields"]
+	if !ok {
+		return nil, false
+	}
+	arr, ok := raw.([]any)
+	if !ok {
+		// Already typed as []map[string]any (unlikely from JSON but handle it)
+		if typed, ok := raw.([]map[string]any); ok {
+			return typed, true
+		}
+		return nil, false
+	}
+	result := make([]map[string]any, 0, len(arr))
+	for _, item := range arr {
+		if m, ok := item.(map[string]any); ok {
+			result = append(result, m)
+		}
+	}
+	return result, len(result) > 0
+}
+
+// hasNonEmptyString is copied verbatim from go-passbolt's helper package, where
+// it is currently unexported. Replace this with the library function once a
+// release exports it.
+//
+// hasNonEmptyString checks if a map entry exists and is a non-empty string.
+func hasNonEmptyString(m map[string]any, key string) bool {
+	v, ok := m[key]
+	if !ok {
+		return false
+	}
+	s, ok := v.(string)
+	return ok && s != ""
+}
+
+// stringifyCustomFieldValue renders a decoded JSON custom field value as a
+// string. json.Unmarshal yields text/number/boolean as string/float64/bool;
+// integers beyond 2^53 may lose precision as float64 before we see them.
+func stringifyCustomFieldValue(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return ""
+	case string:
+		return t
+	case bool:
+		return strconv.FormatBool(t)
+	case float64:
+		return strconv.FormatFloat(t, 'f', -1, 64)
+	default:
+		return fmt.Sprintf("%v", t)
+	}
+}
+
 func assureLoggedIn(ctx context.Context, client *api.Client) error {
 	if client.CheckSession(ctx) {
 		return nil

+ 259 - 5
providers/v1/passbolt/passbolt_test.go

@@ -95,11 +95,12 @@ func TestSecretGetProp(t *testing.T) {
 	g.RegisterTestingT(t)
 
 	secret := Secret{
-		Name:        "test-name",
-		Username:    "test-user",
-		Password:    "test-pass",
-		URI:         "https://test.com",
-		Description: "test-desc",
+		Name:         "test-name",
+		Username:     "test-user",
+		Password:     "test-pass",
+		URI:          "https://test.com",
+		Description:  "test-desc",
+		CustomFields: map[string]string{"my-field": "my-value"},
 	}
 
 	// Test valid properties
@@ -123,11 +124,264 @@ func TestSecretGetProp(t *testing.T) {
 	g.Expect(err).To(g.BeNil())
 	g.Expect(string(val)).To(g.Equal("test-desc"))
 
+	// Test custom field
+	val, err = secret.GetProp("custom_fields.my-field")
+	g.Expect(err).To(g.BeNil())
+	g.Expect(string(val)).To(g.Equal("my-value"))
+
 	// Test invalid property
 	_, err = secret.GetProp("invalid")
 	g.Expect(err).To(g.MatchError(errPassboltSecretPropertyInvalid))
 }
 
+func TestSecretGetPropCustomFieldNotFound(t *testing.T) {
+	g.RegisterTestingT(t)
+
+	// No custom fields set at all.
+	secret := Secret{Name: "test-name"}
+	_, err := secret.GetProp("custom_fields.missing")
+	g.Expect(err).To(g.MatchError(errPassboltCustomFieldNotFound))
+	g.Expect(err).To(g.MatchError(g.ContainSubstring("missing")))
+
+	// Custom fields present but the requested key does not exist.
+	secret.CustomFields = map[string]string{"other-key": "v"}
+	_, err = secret.GetProp("custom_fields.nonexistent")
+	g.Expect(err).To(g.MatchError(errPassboltCustomFieldNotFound))
+	g.Expect(err).To(g.MatchError(g.ContainSubstring("nonexistent")))
+}
+
+func TestBuildCustomFields(t *testing.T) {
+	g.RegisterTestingT(t)
+
+	const idA = "11111111-1111-1111-1111-111111111111"
+	const idB = "22222222-2222-2222-2222-222222222222"
+
+	tests := []struct {
+		name         string
+		metaFields   map[string]any
+		secretFields map[string]any
+		want         map[string]string
+	}{
+		{
+			name:         "no custom_fields in metadata returns nil",
+			metaFields:   map[string]any{"name": "x"},
+			secretFields: map[string]any{"password": "p"},
+			want:         nil,
+		},
+		{
+			name:         "empty custom_fields array returns nil",
+			metaFields:   map[string]any{"custom_fields": []any{}},
+			secretFields: map[string]any{},
+			want:         nil,
+		},
+		{
+			name: "standard case: metadata_key with secret_value",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "metadata_key": "api-key"},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_value": "secret-123"},
+				},
+			},
+			want: map[string]string{"api-key": "secret-123"},
+		},
+		{
+			name: "non-secret field: metadata_key with metadata_value and secret-side stub",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "metadata_key": "env", "metadata_value": "production"},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "type": "text"},
+				},
+			},
+			want: map[string]string{"env": "production"},
+		},
+		{
+			name: "secret_key field (no metadata_key) is silently skipped",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA /* no metadata_key */},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_key": "hidden-name", "secret_value": "hidden-val"},
+				},
+			},
+			want: nil,
+		},
+		{
+			name: "multiple fields: encrypted value and non-secret value",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "metadata_key": "token"},
+					map[string]any{"id": idB, "metadata_key": "region", "metadata_value": "us-east-1"},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_value": "tok-abc123"},
+					map[string]any{"id": idB, "type": "text"},
+				},
+			},
+			want: map[string]string{
+				"token":  "tok-abc123",
+				"region": "us-east-1",
+			},
+		},
+		{
+			name: "secret_value takes precedence over metadata_value",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "metadata_key": "field", "metadata_value": "meta-val"},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_value": "secret-val"},
+				},
+			},
+			want: map[string]string{"field": "secret-val"},
+		},
+		{
+			name: "secret_value of empty string is preserved",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "metadata_key": "empty-field"},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_value": ""},
+				},
+			},
+			want: map[string]string{"empty-field": ""},
+		},
+		{
+			name: "numeric and boolean values are stringified",
+			metaFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "metadata_key": "port"},
+					map[string]any{"id": idB, "metadata_key": "enabled", "metadata_value": true},
+				},
+			},
+			secretFields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_value": float64(8080)},
+					map[string]any{"id": idB, "type": "boolean"},
+				},
+			},
+			want: map[string]string{
+				"port":    "8080",
+				"enabled": "true",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := buildCustomFields(tt.metaFields, tt.secretFields)
+			g.Expect(got).To(g.Equal(tt.want))
+		})
+	}
+}
+
+func TestExtractCustomFields(t *testing.T) {
+	g.RegisterTestingT(t)
+
+	const idA = "11111111-1111-1111-1111-111111111111"
+	const idB = "22222222-2222-2222-2222-222222222222"
+
+	tests := []struct {
+		name   string
+		fields map[string]any
+		want   []map[string]any
+		wantOK bool
+	}{
+		{
+			name:   "no custom_fields key",
+			fields: map[string]any{"password": "p"},
+			want:   nil,
+			wantOK: false,
+		},
+		{
+			name:   "custom_fields not a slice",
+			fields: map[string]any{"custom_fields": "not-a-slice"},
+			want:   nil,
+			wantOK: false,
+		},
+		{
+			name: "already typed as []map[string]any",
+			fields: map[string]any{
+				"custom_fields": []map[string]any{
+					{"id": idA, "secret_value": "tok-abc"},
+				},
+			},
+			want:   []map[string]any{{"id": idA, "secret_value": "tok-abc"}},
+			wantOK: true,
+		},
+		{
+			name: "entries are extracted, non-map items skipped",
+			fields: map[string]any{
+				"custom_fields": []any{
+					map[string]any{"id": idA, "secret_value": "tok-abc"},
+					"not-a-map",
+					map[string]any{"id": idB, "type": "text"},
+				},
+			},
+			want: []map[string]any{
+				{"id": idA, "secret_value": "tok-abc"},
+				{"id": idB, "type": "text"},
+			},
+			wantOK: true,
+		},
+		{
+			name: "empty custom_fields slice returns false",
+			fields: map[string]any{
+				"custom_fields": []any{},
+			},
+			want:   []map[string]any{},
+			wantOK: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, ok := extractCustomFields(tt.fields)
+			g.Expect(ok).To(g.Equal(tt.wantOK))
+			g.Expect(got).To(g.Equal(tt.want))
+		})
+	}
+}
+
+func TestHasNonEmptyString(t *testing.T) {
+	g.RegisterTestingT(t)
+
+	tests := []struct {
+		name string
+		m    map[string]any
+		key  string
+		want bool
+	}{
+		{name: "missing key", m: map[string]any{}, key: "k", want: false},
+		{name: "non-empty string", m: map[string]any{"k": "v"}, key: "k", want: true},
+		{name: "empty string", m: map[string]any{"k": ""}, key: "k", want: false},
+		{name: "non-string value", m: map[string]any{"k": 42}, key: "k", want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			g.Expect(hasNonEmptyString(tt.m, tt.key)).To(g.Equal(tt.want))
+		})
+	}
+}
+
 func TestCapabilities(t *testing.T) {
 	g.RegisterTestingT(t)
 	p := &ProviderPassbolt{}