Browse Source

fix(akeyless): support dataFrom.extract.property for nested JSON (#6633)

* fix(akeyless): support dataFrom.extract.property for nested JSON

Align GetSecretMap with GCP/AWS by unmarshaling into json.RawMessage so
nested subtrees and non-string values sync correctly via extract.property.

Signed-off-by: baraka-akeyless <barak.a@akeyless.io>
Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: share JSON secret map parsing and e2e extract test

Move JSON object parsing into esutils.JSONToSecretDataMap and reuse the
dataFrom.extract.property e2e case from common to reduce duplication.

Signed-off-by: baraka-akeyless <barak.a@akeyless.io>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(esutils): preserve JSON null values in JSONToSecretDataMap

Go unmarshals JSON null into an empty string; detect null explicitly so
non-string JSON types remain raw bytes as documented.

Signed-off-by: baraka-akeyless <barak.a@akeyless.io>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(esutils): align JSON null handling with GCP/AWS

Drop the special-case null branch so JSON null maps to an empty byte
slice, matching the existing GetSecretMap behavior in GCP and AWS.

Signed-off-by: baraka-akeyless <barak.a@akeyless.io>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Signed-off-by: baraka-akeyless <barak.a@akeyless.io>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>
baraka_akeyless 1 week ago
parent
commit
411ea70c38

+ 22 - 0
docs/provider/akeyless.md

@@ -146,6 +146,28 @@ DataFrom can be used to get a secret as a JSON string and attempt to parse it, c
 {% include 'akeyless-external-secret-json.yaml' %}
 ```
 
+When the secret contains nested JSON objects, use `extract.property` to select a subtree before parsing. This is useful when only part of the secret should be synced (for example, extracting a `db` object while ignoring other top-level keys like `apiKey`).
+
+```yaml
+{% include 'akeyless-external-secret-json-property.yaml' %}
+```
+
+Given an Akeyless static secret with value:
+
+```json
+{
+  "db": {
+    "username": "my_user",
+    "password": "my_pass"
+  },
+  "apiKey": "myApiKey"
+}
+```
+
+The example above creates a Kubernetes secret with keys `username` and `password`. Non-string JSON values (numbers, booleans, nested objects) are preserved as their JSON representation.
+
+**Note:** `extract.property` requires the secret value to be valid JSON. It works reliably with **Static Secrets**. Dynamic and Rotated secrets may return provider-specific JSON envelopes; use `remoteRef.property` with `spec.data` for individual fields in those cases.
+
 #### Finding secrets by name or tag
 
 Use `dataFrom.find` to bulk-fetch secrets matching a name pattern or tag:

+ 21 - 0
docs/snippets/akeyless-external-secret-json-property.yaml

@@ -0,0 +1,21 @@
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: database-credentials
+spec:
+  refreshInterval: 1h0m0s
+
+  secretStoreRef:
+    kind: SecretStore
+    name: akeyless-secret-store # Must match SecretStore on the cluster
+
+  target:
+    name: database-credentials-json # Name for the secret to be created on the cluster
+    creationPolicy: Owner
+
+  # Extract only the "db" object from a JSON secret and create one Kubernetes
+  # secret key per field inside it (e.g. username, password).
+  dataFrom:
+  - extract:
+      key: database-credentials # Full path of the secret on Akeyless
+      property: db

+ 1 - 0
e2e/suites/provider/cases/akeyless/akeyless.go

@@ -33,6 +33,7 @@ var _ = Describe("[akeyless]", Label("akeyless"), func() {
 		Entry(common.SimpleDataSync(f)),
 		Entry(common.NestedJSONWithGJSON(f)),
 		Entry(common.JSONDataFromSync(f)),
+		Entry(common.JSONDataFromExtractProperty(f)),
 		Entry(common.JSONDataFromRewrite(f)),
 		Entry(common.JSONDataWithProperty(f)),
 		Entry(common.JSONDataWithTemplate(f)),

+ 28 - 0
e2e/suites/provider/cases/common/common.go

@@ -378,6 +378,34 @@ func JSONDataFromSync(f *framework.Framework) (string, func(*framework.TestCase)
 	}
 }
 
+// JSONDataFromExtractProperty syncs a nested JSON subtree using dataFrom.extract.property.
+func JSONDataFromExtractProperty(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return "[common] should sync nested json secrets with dataFrom extract property", func(tc *framework.TestCase) {
+		secretKey := fmt.Sprintf("%s-%s", f.Namespace.Name, "json-property")
+		remoteRefKey := f.MakeRemoteRefKey(secretKey)
+		secretValue := `{"db":{"username":"my_user","password":"my_pass","port":5432},"apiKey":"myApiKey"}`
+		tc.Secrets = map[string]framework.SecretEntry{
+			remoteRefKey: {Value: secretValue},
+		}
+		tc.ExpectedSecret = &v1.Secret{
+			Type: v1.SecretTypeOpaque,
+			Data: map[string][]byte{
+				"username": []byte("my_user"),
+				"password": []byte("my_pass"),
+				"port":     []byte("5432"),
+			},
+		}
+		tc.ExternalSecret.Spec.DataFrom = []esv1.ExternalSecretDataFromRemoteRef{
+			{
+				Extract: &esv1.ExternalSecretDataRemoteRef{
+					Key:      remoteRefKey,
+					Property: "db",
+				},
+			},
+		}
+	}
+}
+
 // This case creates one secret with json values and syncs them using a single .Spec.DataFrom block.
 func JSONDataFromRewrite(f *framework.Framework) (string, func(*framework.TestCase)) {
 	return "[common] should sync and rewrite secrets with dataFrom", func(tc *framework.TestCase) {

+ 3 - 9
providers/v1/akeyless/akeyless.go

@@ -422,22 +422,16 @@ func (a *Akeyless) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretData
 	if esutils.IsNil(a.Client) {
 		return nil, errors.New(errUninitalizedAkeylessProvider)
 	}
-	val, err := a.GetSecret(ctx, ref)
+	data, err := a.GetSecret(ctx, ref)
 	if err != nil {
 		return nil, err
 	}
-	// Maps the json data to a string:string map
-	kv := make(map[string]string)
-	err = json.Unmarshal(val, &kv)
+
+	secretData, err := esutils.JSONToSecretDataMap(data)
 	if err != nil {
 		return nil, fmt.Errorf(errJSONSecretUnmarshal, err)
 	}
 
-	// Converts values in K:V pairs into bytes, while leaving keys as strings
-	secretData := make(map[string][]byte)
-	for k, v := range kv {
-		secretData[k] = []byte(v)
-	}
 	return secretData, nil
 }
 

+ 29 - 0
providers/v1/akeyless/akeyless_test.go

@@ -378,6 +378,32 @@ func TestGetSecretMap(t *testing.T) {
 		smtc.expectedVal = map[string][]byte{"foo": []byte("bar")}
 	}
 
+	// good case: nested json values are kept as raw json bytes
+	setNestedJSON := func(smtc *akeylessTestCase) {
+		smtc.apiOutput.Value = `{"foobar":{"baz":"nestedval"}}`
+		smtc.expectedVal = map[string][]byte{"foobar": []byte(`{"baz":"nestedval"}`)}
+	}
+
+	// good case: extract a nested json object into multiple keys
+	setExtractProperty := func(smtc *akeylessTestCase) {
+		smtc.apiOutput.Value = `{"db":{"username":"my_user","password":"my_pass"},"apiKey":"myApiKey"}`
+		smtc.ref.Property = "db"
+		smtc.expectedVal = map[string][]byte{
+			"username": []byte("my_user"),
+			"password": []byte("my_pass"),
+		}
+	}
+
+	// good case: extract property with non-string values
+	setExtractPropertyWithNonStringValues := func(smtc *akeylessTestCase) {
+		smtc.apiOutput.Value = `{"db":{"username":"my_user","port":5432}}`
+		smtc.ref.Property = "db"
+		smtc.expectedVal = map[string][]byte{
+			"username": []byte("my_user"),
+			"port":     []byte("5432"),
+		}
+	}
+
 	// bad case: invalid json
 	setInvalidJSON := func(smtc *akeylessTestCase) {
 		smtc.apiOutput.Value = `-----------------`
@@ -386,6 +412,9 @@ func TestGetSecretMap(t *testing.T) {
 
 	successCases := []*akeylessTestCase{
 		makeValidAkeylessTestCaseCustom(setDeserialization),
+		makeValidAkeylessTestCaseCustom(setNestedJSON),
+		makeValidAkeylessTestCaseCustom(setExtractProperty),
+		makeValidAkeylessTestCaseCustom(setExtractPropertyWithNonStringValues),
 		makeValidAkeylessTestCaseCustom(setInvalidJSON).SetExpectVal(map[string][]byte(nil)),
 		makeValidAkeylessTestCaseCustom(setAPIErr).SetExpectVal(map[string][]byte(nil)),
 		makeValidAkeylessTestCaseCustom(setNilMockClient).SetExpectVal(map[string][]byte(nil)),

+ 21 - 0
runtime/esutils/utils.go

@@ -349,6 +349,27 @@ var (
 	ErrSecretType = errors.New("can not handle secret value with type")
 )
 
+// JSONToSecretDataMap unmarshals a JSON object into secret key/value pairs.
+// String values are unquoted; all other JSON types are kept as raw JSON bytes.
+func JSONToSecretDataMap(data []byte) (map[string][]byte, error) {
+	kv := make(map[string]json.RawMessage)
+	if err := json.Unmarshal(data, &kv); err != nil {
+		return nil, err
+	}
+
+	secretData := make(map[string][]byte, len(kv))
+	for k, v := range kv {
+		var strVal string
+		if err := json.Unmarshal(v, &strVal); err == nil {
+			secretData[k] = []byte(strVal)
+		} else {
+			secretData[k] = v
+		}
+	}
+
+	return secretData, nil
+}
+
 // GetByteValueFromMap retrieves a byte value from a map by key.
 func GetByteValueFromMap(data map[string]any, key string) ([]byte, error) {
 	v, ok := data[key]

+ 29 - 0
runtime/esutils/utils_test.go

@@ -921,6 +921,35 @@ func TestFetchValueFromMetadata(t *testing.T) {
 	}
 }
 
+func TestJSONToSecretDataMap(t *testing.T) {
+	t.Run("string values", func(t *testing.T) {
+		got, err := JSONToSecretDataMap([]byte(`{"foo":"bar"}`))
+		assert.NoError(t, err)
+		assert.Equal(t, map[string][]byte{"foo": []byte("bar")}, got)
+	})
+
+	t.Run("nested and non-string values", func(t *testing.T) {
+		got, err := JSONToSecretDataMap([]byte(`{"username":"my_user","port":5432,"nested":{"baz":"nestedval"}}`))
+		assert.NoError(t, err)
+		assert.Equal(t, map[string][]byte{
+			"username": []byte("my_user"),
+			"port":     []byte("5432"),
+			"nested":   []byte(`{"baz":"nestedval"}`),
+		}, got)
+	})
+
+	t.Run("null value", func(t *testing.T) {
+		got, err := JSONToSecretDataMap([]byte(`{"key":null}`))
+		assert.NoError(t, err)
+		assert.Equal(t, map[string][]byte{"key": []byte("")}, got)
+	})
+
+	t.Run("invalid json", func(t *testing.T) {
+		_, err := JSONToSecretDataMap([]byte(`not-json`))
+		assert.Error(t, err)
+	})
+}
+
 func TestGetByteValue(t *testing.T) {
 	type args struct {
 		data any