Browse Source

feat(infisical): return NoSecretErr on 404 and add PushSecret support (#6434)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Signed-off-by: Alexander Chernov <alexander@chernov.it>
Alexander Chernov 2 days ago
parent
commit
c8fc114dc0

+ 1 - 1
docs/introduction/stability-support.md

@@ -128,7 +128,7 @@ The following table show the support for features across different providers.
 | SecretServer              |      x       |              |                      |                         |        x         |      x      |              x              |
 | Pulumi ESC                |      x       |              |                      |                         |        x         |             |                             |
 | Passbolt                  |      x       |              |                      |                         |        x         |             |                             |
-| Infisical                 |      x       |              |                      |            x            |        x         |             |                             |
+| Infisical                 |      x       |              |                      |            x            |        x         |      x      |              x              |
 | Bitwarden Secrets Manager |      x       |              |                      |                         |        x         |      x      |              x              |
 | Previder                  |      x       |              |                      |                         |        x         |             |                             |
 | Cloud.ru                  |      x       |      x       |                      |            x            |        x         |             |              x              |

+ 45 - 3
docs/provider/infisical.md

@@ -1,8 +1,6 @@
 ![Infisical k8s Diagram](../pictures/external-secrets-operator.png)
 
-Sync secrets from [Infisical](https://www.infisical.com) to your Kubernetes cluster using External Secrets Operator.
-
-> **Note**: The Infisical provider is read-only. PushSecret is not supported.
+Sync secrets from [Infisical](https://www.infisical.com) to your Kubernetes cluster using External Secrets Operator, and push secrets from the cluster back into Infisical with `PushSecret`.
 
 ## Authentication
 
@@ -658,6 +656,50 @@ The following restrictions apply:
 
 ---
 
+## Pushing Secrets
+
+The Infisical provider supports `PushSecret`, writing a Kubernetes Secret into an Infisical project. The machine identity used by the store must have write permission on the target project and environment.
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-example
+spec:
+  refreshInterval: 1h
+  secretStoreRefs:
+    - name: infisical
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-source-secret
+  data:
+    - match:
+        secretKey: API_KEY          # key in the Kubernetes Secret
+        remoteRef:
+          remoteKey: API_KEY        # secret name in Infisical
+```
+
+### Remote key resolution
+
+`remoteRef.remoteKey` resolves the target location with the same three rules as `remoteRef.key` on reads (see [Key resolution](#key-resolution-for-remoterefkey)): a bare name lands under `secretsScope.secretsPath`, a leading-slash key is an absolute path, and a relative path is joined onto `secretsScope.secretsPath`.
+
+### Push behavior
+
+- **Single key**: when `secretKey` is set, the value of that key in the source Secret is pushed as the Infisical secret value.
+- **Whole secret**: when `secretKey` is omitted, the entire source Secret is marshaled into a JSON object (`{"key":"value",...}`) and stored as the value of `remoteKey`.
+- **Property**: when `remoteRef.property` is set, the value is written as that JSON property of the remote secret's value, merging with any existing properties rather than overwriting the whole value.
+- **Create vs update**: a missing secret is created; an existing one is updated. If the remote value already matches, the push is skipped so no new secret version is created.
+
+### Deletion
+
+When a `PushSecret` is removed with `deletionPolicy: Delete`, the provider deletes the remote secret. If `remoteRef.property` is set, only that property is removed and the secret is deleted once no properties remain. Deleting an already-absent secret is a no-op.
+
+!!! note
+    The Infisical write API requires the project's internal ID, while the store is configured with a project slug. The provider resolves the slug to its ID automatically and caches the result, so no extra configuration is needed. If a write later fails because the cached ID no longer works (for example the project was deleted and recreated under the same slug), the provider re-resolves the slug once and retries; if the slug no longer maps to a project, the write fails with a clear "no such project" error.
+
+---
+
 ## Custom CA Certificates
 
 If you are using a self-hosted Infisical instance with a self-signed certificate or a certificate signed by a private CA, you can configure the provider to trust it. Set `hostAPI` to the base URL of your Infisical server (without the `/api` suffix -- the operator appends it automatically).

+ 1 - 1
e2e/framework/addon/infisical.go

@@ -76,7 +76,7 @@ type Infisical struct {
 	EnvironmentSlug string
 
 	// SDKClient is logged in via Universal Auth and used by the suite to seed
-	// and remove backend secrets (the provider itself is read-only).
+	// and remove backend secrets.
 	SDKClient infisicalSdk.InfisicalClientInterface
 }
 

+ 25 - 5
e2e/suites/provider/cases/infisical/infisical.go

@@ -24,6 +24,8 @@ import (
 	"github.com/external-secrets/external-secrets-e2e/framework/addon"
 	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/runtime/testing/fake"
 )
 
 const (
@@ -31,19 +33,17 @@ const (
 	withUniversalAuthCluster = "with universal auth and cluster store"
 )
 
-// The Infisical provider is read-only, so the suite seeds secrets through the
-// SDK and exercises the read paths only. PushSecret cases are out of scope.
+// The suite seeds read-path secrets through the SDK and exercises the push
+// path through the provider's PushSecret implementation.
 // FindByTag is excluded because the provider does not implement tag lookup
 // (it returns "find by tags not supported"), and FindByNameWithPath is
 // excluded because the provider matches ref.Path as a prefix of the absolute
 // Infisical secret path, which a bare namespace name never satisfies.
-// DeletionPolicyDelete is excluded because the provider returns the raw API
-// error on a missing secret rather than esv1.NoSecretErr, so ESO never
-// observes the upstream deletion that the policy keys off.
 var _ = Describe("[infisical]", Label("infisical"), Ordered, func() {
 	f := framework.New("infisical")
 	infisical := addon.NewInfisical()
 	prov := newInfisicalProvider(f, infisical)
+	fakeSecretClient := fake.New()
 
 	BeforeAll(func() {
 		addon.InstallGlobalAddon(infisical)
@@ -68,9 +68,18 @@ var _ = Describe("[infisical]", Label("infisical"), Ordered, func() {
 		framework.Compose(withUniversalAuth, f, common.SSHKeySyncDataProperty, useUniversalAuth(prov)),
 		framework.Compose(withUniversalAuth, f, common.DecodingPolicySync, useUniversalAuth(prov)),
 		framework.Compose(withUniversalAuth, f, common.StatusNotUpdatedAfterSuccessfulSync, useUniversalAuth(prov)),
+		// DeletionPolicyDelete depends on GetSecret returning NoSecretErr for a
+		// missing key, which the provider now does (see issue #6413).
+		framework.Compose(withUniversalAuth, f, common.DeletionPolicyDelete, useUniversalAuth(prov)),
 		// one case through a ClusterSecretStore to cover the cluster-scoped path
 		framework.Compose(withUniversalAuthCluster, f, common.JSONDataFromSync, useUniversalAuthClusterStore(prov)),
 	)
+
+	DescribeTable("push secrets",
+		framework.TableFuncWithPushSecret(f, prov, fakeSecretClient),
+		framework.Compose(withUniversalAuth, f, pushSecretValue(prov), useUniversalAuthForPush(prov)),
+		framework.Compose(withUniversalAuth, f, pushSecretDeletesOnPolicy(prov), useUniversalAuthForPush(prov)),
+	)
 })
 
 func useUniversalAuth(prov *infisicalProvider) func(*framework.TestCase) {
@@ -87,3 +96,14 @@ func useUniversalAuthClusterStore(prov *infisicalProvider) func(*framework.TestC
 		tc.ExternalSecret.Spec.SecretStoreRef.Kind = esv1.ClusterSecretStoreKind
 	}
 }
+
+// useUniversalAuthForPush creates the namespaced Universal Auth store and points
+// the test's PushSecret at it.
+func useUniversalAuthForPush(prov *infisicalProvider) func(*framework.TestCase) {
+	return func(tc *framework.TestCase) {
+		prov.CreateUniversalAuthStore()
+		tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{
+			{Name: tc.Framework.Namespace.Name},
+		}
+	}
+}

+ 225 - 0
e2e/suites/provider/cases/infisical/push.go

@@ -0,0 +1,225 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package infisical
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	infisicalSdk "github.com/infisical/go-sdk"
+	sdkErrors "github.com/infisical/go-sdk/packages/errors"
+	//nolint
+	. "github.com/onsi/ginkgo/v2"
+	//nolint
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/wait"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+// pushSecretValue pushes a single value through the provider's PushSecret
+// implementation, then reads it back with an ExternalSecret to confirm the
+// round-trip. The remote key is namespaced so parallel specs sharing the e2e
+// project do not collide.
+func pushSecretValue(prov *infisicalProvider) func(*framework.Framework) (string, func(*framework.TestCase)) {
+	return func(f *framework.Framework) (string, func(*framework.TestCase)) {
+		return "[infisical] should push a secret and read it back", func(tc *framework.TestCase) {
+			sourceName := fmt.Sprintf("%s-src", f.Namespace.Name)
+			remoteKey := fmt.Sprintf("%s-pushed", f.Namespace.Name)
+			value := "pushed-value"
+
+			tc.PushSecretSource = &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      sourceName,
+					Namespace: f.Namespace.Name,
+				},
+				Type: v1.SecretTypeOpaque,
+				Data: map[string][]byte{"credential": []byte(value)},
+			}
+			tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+				Secret: &esv1alpha1.PushSecretSecret{Name: sourceName},
+			}
+			tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{
+				{
+					Match: esv1alpha1.PushSecretMatch{
+						SecretKey: "credential",
+						RemoteRef: esv1alpha1.PushSecretRemoteRef{RemoteKey: remoteKey},
+					},
+				},
+			}
+
+			tc.VerifyPushSecretOutcome = func(_ *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				// Remove the pushed secret from the shared project so re-runs
+				// start clean, regardless of controller teardown ordering.
+				DeferCleanup(func() { prov.deleteRemote(remoteKey) })
+
+				Eventually(func() bool {
+					ps := &esv1alpha1.PushSecret{}
+					err := f.CRClient.Get(GinkgoT().Context(), types.NamespacedName{
+						Name:      tc.PushSecret.Name,
+						Namespace: tc.PushSecret.Namespace,
+					}, ps)
+					Expect(err).ToNot(HaveOccurred())
+					for i := range ps.Status.Conditions {
+						c := ps.Status.Conditions[i]
+						if c.Type == esv1alpha1.PushSecretReady && c.Status == v1.ConditionTrue {
+							return true
+						}
+					}
+					return false
+				}, time.Minute*2, time.Second*5).Should(BeTrue())
+
+				// Read the pushed value back through an ExternalSecret.
+				const target = "push-readback"
+				es := &esv1.ExternalSecret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "e2e-push-es",
+						Namespace: f.Namespace.Name,
+					},
+					Spec: esv1.ExternalSecretSpec{
+						RefreshInterval: &metav1.Duration{Duration: time.Second * 5},
+						SecretStoreRef:  esv1.SecretStoreRef{Name: f.Namespace.Name},
+						Target:          esv1.ExternalSecretTarget{Name: target},
+						Data: []esv1.ExternalSecretData{
+							{
+								SecretKey: target,
+								RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: remoteKey},
+							},
+						},
+					},
+				}
+				Expect(f.CRClient.Create(GinkgoT().Context(), es)).ToNot(HaveOccurred())
+
+				readBack := &v1.Secret{}
+				err := wait.PollUntilContextTimeout(GinkgoT().Context(), time.Second*5, time.Minute*2, true, func(ctx context.Context) (bool, error) {
+					gerr := f.CRClient.Get(ctx, types.NamespacedName{Namespace: f.Namespace.Name, Name: target}, readBack)
+					if apierrors.IsNotFound(gerr) {
+						return false, nil
+					}
+					return gerr == nil, gerr
+				})
+				Expect(err).ToNot(HaveOccurred())
+				Expect(string(readBack.Data[target])).To(Equal(value))
+			}
+		}
+	}
+}
+
+// pushSecretDeletesOnPolicy pushes a secret with deletionPolicy: Delete, then
+// deletes the PushSecret and confirms the operator removed the secret from
+// Infisical through the provider's DeleteSecret.
+func pushSecretDeletesOnPolicy(prov *infisicalProvider) func(*framework.Framework) (string, func(*framework.TestCase)) {
+	return func(f *framework.Framework) (string, func(*framework.TestCase)) {
+		return "[infisical] should delete the remote secret when the PushSecret is deleted", func(tc *framework.TestCase) {
+			sourceName := fmt.Sprintf("%s-del-src", f.Namespace.Name)
+			remoteKey := fmt.Sprintf("%s-del", f.Namespace.Name)
+			value := "delete-me"
+
+			tc.PushSecretSource = &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{Name: sourceName, Namespace: f.Namespace.Name},
+				Type:       v1.SecretTypeOpaque,
+				Data:       map[string][]byte{"credential": []byte(value)},
+			}
+			tc.PushSecret.Spec.DeletionPolicy = esv1alpha1.PushSecretDeletionPolicyDelete
+			tc.PushSecret.Spec.Selector = esv1alpha1.PushSecretSelector{
+				Secret: &esv1alpha1.PushSecretSecret{Name: sourceName},
+			}
+			tc.PushSecret.Spec.Data = []esv1alpha1.PushSecretData{
+				{
+					Match: esv1alpha1.PushSecretMatch{
+						SecretKey: "credential",
+						RemoteRef: esv1alpha1.PushSecretRemoteRef{RemoteKey: remoteKey},
+					},
+				},
+			}
+
+			tc.VerifyPushSecretOutcome = func(ps *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				// Best-effort guard in case the deletion assertion below fails.
+				DeferCleanup(func() { prov.deleteRemote(remoteKey) })
+
+				// The push lands the secret in Infisical.
+				Eventually(func() error {
+					_, err := prov.remoteSecretValue(remoteKey)
+					return err
+				}, time.Minute*2, time.Second*5).Should(Succeed())
+
+				// Deleting the PushSecret must drive the provider's DeleteSecret
+				// (deletionPolicy: Delete), removing the secret from Infisical.
+				Expect(f.CRClient.Delete(GinkgoT().Context(), ps)).To(Succeed())
+
+				// Wait until the secret is confirmed absent. Using
+				// Should(Succeed()) on a helper that returns nil only on
+				// "not found" avoids a false pass on transient API errors
+				// (which ShouldNot(Succeed()) would also accept).
+				Eventually(func() error {
+					_, err := prov.remoteSecretValue(remoteKey)
+					if err == nil {
+						return fmt.Errorf("secret %q still exists in Infisical", remoteKey)
+					}
+					if isInfisicalNotFound(err) {
+						return nil
+					}
+					return err
+				}, time.Minute*2, time.Second*5).Should(Succeed())
+			}
+		}
+	}
+}
+
+// isInfisicalNotFound reports whether err is a 404 from the Infisical API,
+// meaning the secret is definitively absent rather than transiently unavailable.
+func isInfisicalNotFound(err error) bool {
+	var apiErr *sdkErrors.APIError
+	return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
+}
+
+// remoteSecretValue reads a secret from the e2e project by slug via the addon
+// SDK; it returns an error when the secret is absent.
+func (s *infisicalProvider) remoteSecretValue(key string) (string, error) {
+	secretPath, name := secretAddress(scopePath, key)
+	secret, err := s.addon.SDKClient.Secrets().Retrieve(infisicalSdk.RetrieveSecretOptions{
+		ProjectSlug: s.addon.ProjectSlug,
+		Environment: s.addon.EnvironmentSlug,
+		SecretKey:   name,
+		SecretPath:  secretPath,
+	})
+	if err != nil {
+		return "", err
+	}
+	return secret.SecretValue, nil
+}
+
+// deleteRemote best-effort removes a secret from the shared e2e project via the
+// addon SDK, used to clean up after a push spec.
+func (s *infisicalProvider) deleteRemote(key string) {
+	secretPath, name := secretAddress(scopePath, key)
+	_, _ = s.addon.SDKClient.Secrets().Delete(infisicalSdk.DeleteSecretOptions{
+		ProjectID:   s.addon.ProjectID,
+		Environment: s.addon.EnvironmentSlug,
+		SecretPath:  secretPath,
+		SecretKey:   name,
+	})
+}

+ 16 - 20
providers/v1/infisical/client.go

@@ -22,12 +22,13 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net/http"
 	"path"
 	"strings"
 
 	infisical "github.com/infisical/go-sdk"
+	sdkErrors "github.com/infisical/go-sdk/packages/errors"
 	"github.com/tidwall/gjson"
-	corev1 "k8s.io/api/core/v1"
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	"github.com/external-secrets/external-secrets/providers/v1/infisical/constants"
@@ -36,7 +37,6 @@ import (
 )
 
 var (
-	errNotImplemented     = errors.New("not implemented")
 	errPropertyNotFound   = "property %s does not exist in secret %s"
 	errTagsNotImplemented = errors.New("find by tags not supported")
 )
@@ -46,6 +46,14 @@ const (
 	getSecretByKeyV3 = "GetSecretByKeyV3"
 )
 
+// isNotFoundError reports whether err is an Infisical API error with HTTP 404.
+// The go-sdk wraps transport failures in *sdkErrors.APIError, which carries the
+// upstream StatusCode.
+func isNotFoundError(err error) bool {
+	var apiErr *sdkErrors.APIError
+	return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
+}
+
 func getPropertyValue(jsonData, propertyName, keyName string) ([]byte, error) {
 	result := gjson.Get(jsonData, propertyName)
 	if !result.Exists() {
@@ -94,6 +102,12 @@ func (p *Provider) GetSecret(_ context.Context, ref esv1.ExternalSecretDataRemot
 	metrics.ObserveAPICall(constants.ProviderName, getSecretByKeyV3, err)
 
 	if err != nil {
+		// Translate a 404 into the NoSecret sentinel so deletionPolicy: Delete
+		// can prune the entry and a missing key reports as not-found rather
+		// than a generic sync error.
+		if isNotFoundError(err) {
+			return nil, esv1.NoSecretErr
+		}
 		return nil, err
 	}
 
@@ -207,21 +221,3 @@ func (p *Provider) Validate() (esv1.ValidationResult, error) {
 
 	return esv1.ValidationResultReady, nil
 }
-
-// PushSecret will write a single secret into the provider.
-// This is not implemented for this provider.
-func (p *Provider) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
-	return errNotImplemented
-}
-
-// DeleteSecret will delete the secret from a provider.
-// This is not implemented for this provider.
-func (p *Provider) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
-	return errNotImplemented
-}
-
-// SecretExists checks if a secret is already present in the provider at the given location.
-// This is not implemented for this provider.
-func (p *Provider) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
-	return false, errNotImplemented
-}

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

@@ -8,6 +8,7 @@ require (
 	github.com/infisical/go-sdk v0.5.100
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
+	github.com/tidwall/sjson v1.2.5
 	k8s.io/api v0.35.2
 	k8s.io/apimachinery v0.35.2
 	sigs.k8s.io/controller-runtime v0.23.3

+ 3 - 0
providers/v1/infisical/go.sum

@@ -220,6 +220,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
@@ -228,6 +229,8 @@ github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT
 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
 github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=

+ 114 - 56
providers/v1/infisical/provider.go

@@ -18,6 +18,8 @@ package infisical
 
 import (
 	"context"
+	"crypto/sha256"
+	"encoding/hex"
 	"errors"
 	"fmt"
 
@@ -55,6 +57,13 @@ type Provider struct {
 	sdkClient       infisicalSdk.InfisicalClientInterface
 	apiScope        *ClientScope
 	authMethod      string
+
+	// hostAPI, caCertificate and authIdentity back the project-ID resolution
+	// the write endpoints need (see push_secret.go): the SecretStore carries a
+	// project slug but Create/Update/Delete require the project UUID.
+	hostAPI       string
+	caCertificate string
+	authIdentity  string
 }
 
 // ClientScope represents the scope configuration for an Infisical client.
@@ -72,9 +81,17 @@ var _ esv1.Provider = &Provider{}
 
 // Capabilities returns the provider's supported capabilities.
 func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
-	return esv1.SecretStoreReadOnly
+	return esv1.SecretStoreReadWrite
 }
 
+// Each perform*Login function authenticates the SDK client and returns the
+// machine-identity discriminator it resolved (the value already read for login,
+// so no second credential read is needed). NewClient hashes that value into
+// authIdentity to scope the project-ID cache per credential. The returned
+// string is empty only when the accompanying error is non-nil.
+
+// performUniversalAuthLogin logs in via Universal Auth and returns the client
+// ID as the cache discriminator.
 func performUniversalAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -82,28 +99,30 @@ func performUniversalAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	universalAuthCredentials := infisicalSpec.Auth.UniversalAuthCredentials
 	clientID, err := GetStoreSecretData(ctx, store, kube, namespace, universalAuthCredentials.ClientID)
 	if err != nil {
-		return err
+		return "", err
 	}
 
 	clientSecret, err := GetStoreSecretData(ctx, store, kube, namespace, universalAuthCredentials.ClientSecret)
 	if err != nil {
-		return err
+		return "", err
 	}
 
 	_, err = sdkClient.Auth().UniversalAuthLogin(clientID, clientSecret)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaUniversalAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via universal auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via universal auth %w", err)
 	}
 
-	return nil
+	return clientID, nil
 }
 
+// performAzureAuthLogin logs in via Azure Auth and returns the machine
+// identity ID as the cache discriminator.
 func performAzureAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -111,11 +130,11 @@ func performAzureAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	azureAuthCredentials := infisicalSpec.Auth.AzureAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, azureAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data id %w", err)
+		return "", fmt.Errorf("failed to get secret data id %w", err)
 	}
 
 	resource := ""
@@ -123,7 +142,7 @@ func performAzureAuthLogin(
 		resource, err = GetStoreSecretData(ctx, store, kube, namespace, azureAuthCredentials.Resource)
 
 		if err != nil {
-			return fmt.Errorf("failed to get secret data resource %w", err)
+			return "", fmt.Errorf("failed to get secret data resource %w", err)
 		}
 	}
 
@@ -131,12 +150,14 @@ func performAzureAuthLogin(
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaAzureAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via azure auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via azure auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performGcpIDTokenAuthLogin logs in via GCP ID-token Auth and returns the
+// machine identity ID as the cache discriminator.
 func performGcpIDTokenAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -144,23 +165,25 @@ func performGcpIDTokenAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	gcpIDTokenAuthCredentials := infisicalSpec.Auth.GcpIDTokenAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, gcpIDTokenAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	_, err = sdkClient.Auth().GcpIdTokenAuthLogin(identityID)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaGCPIDTokenAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via gcp id token auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via gcp id token auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performGcpIamAuthLogin logs in via GCP IAM Auth and returns the machine
+// identity ID as the cache discriminator.
 func performGcpIamAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -168,28 +191,30 @@ func performGcpIamAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	gcpIamAuthCredentials := infisicalSpec.Auth.GcpIamAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, gcpIamAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	serviceAccountKeyFilePath, err := GetStoreSecretData(ctx, store, kube, namespace, gcpIamAuthCredentials.ServiceAccountKeyFilePath)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data serviceAccountKeyFilePath %w", err)
+		return "", fmt.Errorf("failed to get secret data serviceAccountKeyFilePath %w", err)
 	}
 
 	_, err = sdkClient.Auth().GcpIamAuthLogin(identityID, serviceAccountKeyFilePath)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaGcpServiceAccountAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via gcp iam auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via gcp iam auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performJwtAuthLogin logs in via JWT Auth and returns the machine identity ID
+// as the cache discriminator.
 func performJwtAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -197,28 +222,30 @@ func performJwtAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	jwtAuthCredentials := infisicalSpec.Auth.JwtAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, jwtAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	jwt, err := GetStoreSecretData(ctx, store, kube, namespace, jwtAuthCredentials.JWT)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data jwt %w", err)
+		return "", fmt.Errorf("failed to get secret data jwt %w", err)
 	}
 
 	_, err = sdkClient.Auth().JwtAuthLogin(identityID, jwt)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaJwtAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via jwt auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via jwt auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performLdapAuthLogin logs in via LDAP Auth and returns the machine identity
+// ID as the cache discriminator.
 func performLdapAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -226,33 +253,35 @@ func performLdapAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	ldapAuthCredentials := infisicalSpec.Auth.LdapAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, ldapAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	ldapPassword, err := GetStoreSecretData(ctx, store, kube, namespace, ldapAuthCredentials.LDAPPassword)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data ldapPassword %w", err)
+		return "", fmt.Errorf("failed to get secret data ldapPassword %w", err)
 	}
 
 	ldapUsername, err := GetStoreSecretData(ctx, store, kube, namespace, ldapAuthCredentials.LDAPUsername)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data ldapUsername %w", err)
+		return "", fmt.Errorf("failed to get secret data ldapUsername %w", err)
 	}
 
 	_, err = sdkClient.Auth().LdapAuthLogin(identityID, ldapPassword, ldapUsername)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaLdapAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via ldap auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via ldap auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performOciAuthLogin logs in via OCI Auth and returns the machine identity ID
+// as the cache discriminator.
 func performOciAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -260,45 +289,45 @@ func performOciAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	ociAuthCredentials := infisicalSpec.Auth.OciAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	privateKey, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.PrivateKey)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data privateKey %w", err)
+		return "", fmt.Errorf("failed to get secret data privateKey %w", err)
 	}
 
 	var privateKeyPassphrase *string
 	if ociAuthCredentials.PrivateKeyPassphrase.Name != "" {
 		passphrase, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.PrivateKeyPassphrase)
 		if err != nil {
-			return fmt.Errorf("failed to get secret data privateKeyPassphrase %w", err)
+			return "", fmt.Errorf("failed to get secret data privateKeyPassphrase %w", err)
 		}
 		privateKeyPassphrase = &passphrase
 	}
 
 	fingerprint, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.Fingerprint)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data fingerprint %w", err)
+		return "", fmt.Errorf("failed to get secret data fingerprint %w", err)
 	}
 
 	userID, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.UserID)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data userId %w", err)
+		return "", fmt.Errorf("failed to get secret data userId %w", err)
 	}
 
 	tenancyID, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.TenancyID)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data tenancyId %w", err)
+		return "", fmt.Errorf("failed to get secret data tenancyId %w", err)
 	}
 
 	region, err := GetStoreSecretData(ctx, store, kube, namespace, ociAuthCredentials.Region)
 	if err != nil {
-		return fmt.Errorf("failed to get secret data region %w", err)
+		return "", fmt.Errorf("failed to get secret data region %w", err)
 	}
 
 	_, err = sdkClient.Auth().OciAuthLogin(infisicalSdk.OciAuthLoginOptions{
@@ -313,12 +342,14 @@ func performOciAuthLogin(
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaOciAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via oci auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via oci auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performKubernetesAuthLogin logs in via Kubernetes Auth and returns the
+// machine identity ID as the cache discriminator.
 func performKubernetesAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -326,11 +357,11 @@ func performKubernetesAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	kubernetesAuthCredentials := infisicalSpec.Auth.KubernetesAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, kubernetesAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	serviceAccountTokenPath := ""
@@ -338,7 +369,7 @@ func performKubernetesAuthLogin(
 		serviceAccountTokenPath, err = GetStoreSecretData(ctx, store, kube, namespace, kubernetesAuthCredentials.ServiceAccountTokenPath)
 
 		if err != nil {
-			return fmt.Errorf("failed to get secret data serviceAccountTokenPath %w", err)
+			return "", fmt.Errorf("failed to get secret data serviceAccountTokenPath %w", err)
 		}
 	}
 
@@ -346,12 +377,14 @@ func performKubernetesAuthLogin(
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaKubernetesAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via kubernetes auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via kubernetes auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performAwsAuthLogin logs in via AWS IAM Auth and returns the machine
+// identity ID as the cache discriminator.
 func performAwsAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -359,23 +392,26 @@ func performAwsAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	awsAuthCredentials := infisicalSpec.Auth.AwsAuthCredentials
 	identityID, err := GetStoreSecretData(ctx, store, kube, namespace, awsAuthCredentials.IdentityID)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	_, err = sdkClient.Auth().AwsIamAuthLogin(identityID)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaAwsAuth, err)
 
 	if err != nil {
-		return fmt.Errorf("failed to authenticate via aws auth %w", err)
+		return "", fmt.Errorf("failed to authenticate via aws auth %w", err)
 	}
 
-	return nil
+	return identityID, nil
 }
 
+// performTokenAuthLogin sets the SDK client's pre-generated access token and
+// returns it as the cache discriminator (there is no separate identity ID for
+// token auth).
 func performTokenAuthLogin(
 	ctx context.Context,
 	store esv1.GenericStore,
@@ -383,17 +419,17 @@ func performTokenAuthLogin(
 	sdkClient infisicalSdk.InfisicalClientInterface,
 	kube kclient.Client,
 	namespace string,
-) error {
+) (string, error) {
 	tokenAuthCredentials := infisicalSpec.Auth.TokenAuthCredentials
 	accessToken, err := GetStoreSecretData(ctx, store, kube, namespace, tokenAuthCredentials.AccessToken)
 	if err != nil {
-		return fmt.Errorf(errSecretDataFormat, err)
+		return "", fmt.Errorf(errSecretDataFormat, err)
 	}
 
 	sdkClient.Auth().SetAccessToken(accessToken)
 	metrics.ObserveAPICall(constants.ProviderName, machineIdentityLoginViaTokenAuth, err)
 
-	return nil
+	return accessToken, nil
 }
 
 // NewClient creates a new Infisical client.
@@ -435,7 +471,7 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 		secretPath = "/"
 	}
 
-	var loginFn func(ctx context.Context, store esv1.GenericStore, infisicalSpec *esv1.InfisicalProvider, sdkClient infisicalSdk.InfisicalClientInterface, kube kclient.Client, namespace string) error
+	var loginFn func(ctx context.Context, store esv1.GenericStore, infisicalSpec *esv1.InfisicalProvider, sdkClient infisicalSdk.InfisicalClientInterface, kube kclient.Client, namespace string) (string, error)
 	var authMethod string
 	switch {
 	case infisicalSpec.Auth.UniversalAuthCredentials != nil:
@@ -473,11 +509,19 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 		return nil, errors.New("authentication method not found")
 	}
 
-	if err := loginFn(ctx, store, infisicalSpec, sdkClient, kube, namespace); err != nil {
+	identity, err := loginFn(ctx, store, infisicalSpec, sdkClient, kube, namespace)
+	if err != nil {
 		cancelSdkClient()
 		return nil, err
 	}
 
+	// hostAPI mirrors the SiteUrl the SDK uses; default it the same way the SDK
+	// does so the write-path project lookup targets the right instance.
+	hostAPI := infisicalSpec.HostAPI
+	if hostAPI == "" {
+		hostAPI = "https://app.infisical.com"
+	}
+
 	return &Provider{
 		cancelSdkClient: cancelSdkClient,
 		sdkClient:       sdkClient,
@@ -488,10 +532,24 @@ func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube
 			SecretPath:             secretPath,
 			ExpandSecretReferences: infisicalSpec.SecretsScope.ExpandSecretReferences,
 		},
-		authMethod: authMethod,
+		authMethod:    authMethod,
+		hostAPI:       hostAPI,
+		caCertificate: caCertificate,
+		authIdentity:  hashAuthIdentity(authMethod, identity),
 	}, nil
 }
 
+// hashAuthIdentity returns a stable, non-secret discriminator for the
+// authenticating machine identity, used to scope the project-ID cache so a
+// project slug reused across tenants on a shared host cannot collide. The
+// identity (client ID, identity ID, or access token) comes from the login
+// step, so no second credential read is needed. It is hashed so a token-auth
+// credential is never held verbatim as a map key.
+func hashAuthIdentity(authMethod, identity string) string {
+	sum := sha256.Sum256([]byte(authMethod + "\x00" + identity))
+	return hex.EncodeToString(sum[:])
+}
+
 // Close releases any resources used by the provider.
 func (p *Provider) Close(_ context.Context) error {
 	p.cancelSdkClient()

+ 4 - 1
providers/v1/infisical/provider_test.go

@@ -125,7 +125,10 @@ func TestGetSecret(t *testing.T) {
 				assert.NoError(t, err)
 				assert.Equal(t, tc.Output, output)
 			} else {
-				assert.ErrorAs(t, err, &tc.Error)
+				// ErrorIs, not ErrorAs against an *error target: the latter
+				// matches any non-nil error and would not catch a regression
+				// where GetSecret returns the raw 404 instead of NoSecretErr.
+				assert.ErrorIs(t, err, tc.Error)
 			}
 		})
 	}

+ 476 - 0
providers/v1/infisical/push_secret.go

@@ -0,0 +1,476 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package infisical
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"sync"
+	"time"
+
+	infisical "github.com/infisical/go-sdk"
+	"github.com/tidwall/gjson"
+	"github.com/tidwall/sjson"
+	corev1 "k8s.io/api/core/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/providers/v1/infisical/constants"
+	"github.com/external-secrets/external-secrets/runtime/metrics"
+)
+
+const (
+	createSecretV3     = "CreateSecretV3"
+	updateSecretV3     = "UpdateSecretV3"
+	deleteSecretV3     = "DeleteSecretV3"
+	getWorkspaceBySlug = "GetWorkspaceBySlug"
+
+	// projectLookupTimeout bounds the single slug->ID resolution call.
+	projectLookupTimeout = 30 * time.Second
+)
+
+var (
+	errMissingRemoteKey    = errors.New("remoteKey is required to push a secret to Infisical")
+	errPushSecretKeyFormat = "secret key %q not found in the source secret"
+)
+
+// projectIDCache memoizes slug -> project ID resolution. The write endpoints
+// require the project's UUID (workspaceId), but the SecretStore only carries a
+// slug; the read endpoints resolve the slug server-side, the write ones do not.
+// A project's ID is effectively immutable for a given slug, so entries never
+// expire. The key is scoped by host + auth identity so a slug reused across
+// tenants on shared SaaS never returns another tenant's project ID.
+//
+// TODO: replace sync.Map with a size-capped LRU (or runtime/cache.Must) to
+// bound memory when many token-auth stores rotate their credentials frequently;
+// each rotation hashes to a distinct key and the stale entry is never evicted.
+var projectIDCache sync.Map // map[projectIDCacheKey]string
+
+type projectIDCacheKey struct {
+	host     string
+	slug     string
+	identity string
+}
+
+// PushSecret writes a single secret into Infisical, creating it when absent and
+// updating it otherwise. With a property set, the value is merged as a JSON
+// property of the remote secret's value.
+func (p *Provider) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
+	remoteKey := data.GetRemoteKey()
+	if remoteKey == "" {
+		return errMissingRemoteKey
+	}
+
+	payload, err := pushPayload(secret, data)
+	if err != nil {
+		return err
+	}
+
+	secretPath, name := getSecretAddress(p.apiScope.SecretPath, remoteKey)
+	existing, found, err := p.fetchExisting(secretPath, name)
+	if err != nil {
+		return err
+	}
+
+	value := string(payload)
+	if property := data.GetProperty(); property != "" {
+		base := "{}"
+		if found {
+			base = existing
+		}
+		merged, serr := sjson.Set(base, property, string(payload))
+		if serr != nil {
+			return fmt.Errorf("failed to set property %q on secret %q: %w", property, name, serr)
+		}
+		value = merged
+	}
+
+	if found {
+		// Already in the desired state: skip the write so we do not create a
+		// new secret version on every reconcile.
+		if existing == value {
+			return nil
+		}
+		return p.withResolvedProject(ctx, func(projectID string) error {
+			return p.updateSecret(projectID, secretPath, name, value)
+		})
+	}
+	return p.withResolvedProject(ctx, func(projectID string) error {
+		return p.createSecret(projectID, secretPath, name, value)
+	})
+}
+
+// DeleteSecret removes a secret from Infisical. With a property set, only that
+// property is removed; the secret itself is deleted once no properties remain.
+// A missing secret is treated as already deleted.
+func (p *Provider) DeleteSecret(ctx context.Context, remoteRef esv1.PushSecretRemoteRef) error {
+	remoteKey := remoteRef.GetRemoteKey()
+	if remoteKey == "" {
+		return errMissingRemoteKey
+	}
+
+	secretPath, name := getSecretAddress(p.apiScope.SecretPath, remoteKey)
+
+	// Check existence first via the slug-based read path (which resolves the
+	// project server-side, so it never depends on a cached ID). An absent
+	// secret is already deleted; returning here also means a later 404 from the
+	// write is unambiguously a stale project, not a missing secret.
+	existing, found, err := p.fetchExisting(secretPath, name)
+	if err != nil {
+		return err
+	}
+	if !found {
+		return nil
+	}
+
+	if property := remoteRef.GetProperty(); property != "" {
+		if !gjson.Parse(existing).IsObject() {
+			return fmt.Errorf("secret %q value is not a JSON object; cannot delete property %q", name, property)
+		}
+		if !gjson.Get(existing, property).Exists() {
+			return nil
+		}
+		updated, derr := sjson.Delete(existing, property)
+		if derr != nil {
+			return fmt.Errorf("failed to delete property %q on secret %q: %w", property, name, derr)
+		}
+		// Keep the secret as long as other properties remain.
+		if objectKeyCount(updated) > 0 {
+			return p.withResolvedProject(ctx, func(projectID string) error {
+				return p.updateSecret(projectID, secretPath, name, updated)
+			})
+		}
+	}
+
+	err = p.withResolvedProject(ctx, func(projectID string) error {
+		_, derr := p.sdkClient.Secrets().Delete(infisical.DeleteSecretOptions{
+			ProjectID:   projectID,
+			Environment: p.apiScope.EnvironmentSlug,
+			SecretPath:  secretPath,
+			SecretKey:   name,
+		})
+		metrics.ObserveAPICall(constants.ProviderName, deleteSecretV3, derr)
+		return derr
+	})
+	if err != nil {
+		// The secret existed a moment ago, so a 404 now means a concurrent
+		// delete; treat it as already gone.
+		if isNotFoundError(err) {
+			return nil
+		}
+		return fmt.Errorf("failed to delete secret %q: %w", name, err)
+	}
+	return nil
+}
+
+// SecretExists reports whether the referenced secret (and property, if set) is
+// present in Infisical.
+func (p *Provider) SecretExists(_ context.Context, remoteRef esv1.PushSecretRemoteRef) (bool, error) {
+	remoteKey := remoteRef.GetRemoteKey()
+	if remoteKey == "" {
+		return false, errMissingRemoteKey
+	}
+
+	secretPath, name := getSecretAddress(p.apiScope.SecretPath, remoteKey)
+	existing, found, err := p.fetchExisting(secretPath, name)
+	if err != nil {
+		return false, err
+	}
+	if !found {
+		return false, nil
+	}
+	if property := remoteRef.GetProperty(); property != "" {
+		if !gjson.Parse(existing).IsObject() {
+			return false, fmt.Errorf("secret %q value is not a JSON object; cannot check property %q", name, property)
+		}
+		return gjson.Get(existing, property).Exists(), nil
+	}
+	return true, nil
+}
+
+// pushPayload resolves the bytes to write: a single key when SecretKey is set,
+// otherwise the whole secret marshaled as a JSON object of its string values.
+func pushPayload(secret *corev1.Secret, data esv1.PushSecretData) ([]byte, error) {
+	if key := data.GetSecretKey(); key != "" {
+		v, ok := secret.Data[key]
+		if !ok {
+			return nil, fmt.Errorf(errPushSecretKeyFormat, key)
+		}
+		return v, nil
+	}
+
+	m := make(map[string]string, len(secret.Data))
+	for k, v := range secret.Data {
+		m[k] = string(v)
+	}
+	b, err := json.Marshal(m)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal secret data: %w", err)
+	}
+	return b, nil
+}
+
+// fetchExisting retrieves the current remote value, reporting found=false on a
+// 404 so callers can distinguish "absent" from a transport error.
+func (p *Provider) fetchExisting(secretPath, name string) (string, bool, error) {
+	secret, err := p.sdkClient.Secrets().Retrieve(infisical.RetrieveSecretOptions{
+		Environment:            p.apiScope.EnvironmentSlug,
+		ProjectSlug:            p.apiScope.ProjectSlug,
+		SecretKey:              name,
+		SecretPath:             secretPath,
+		ExpandSecretReferences: p.apiScope.ExpandSecretReferences,
+	})
+	metrics.ObserveAPICall(constants.ProviderName, getSecretByKeyV3, err)
+	if err != nil {
+		if isNotFoundError(err) {
+			return "", false, nil
+		}
+		return "", false, err
+	}
+	return secret.SecretValue, true, nil
+}
+
+// createSecret creates a new secret named name with the given value at
+// secretPath in the resolved project.
+func (p *Provider) createSecret(projectID, secretPath, name, value string) error {
+	_, err := p.sdkClient.Secrets().Create(infisical.CreateSecretOptions{
+		ProjectID:             projectID,
+		Environment:           p.apiScope.EnvironmentSlug,
+		SecretPath:            secretPath,
+		SecretKey:             name,
+		SecretValue:           value,
+		SkipMultiLineEncoding: true,
+	})
+	metrics.ObserveAPICall(constants.ProviderName, createSecretV3, err)
+	if err != nil {
+		return fmt.Errorf("failed to create secret %q: %w", name, err)
+	}
+	return nil
+}
+
+// updateSecret overwrites the value of the existing secret named name at
+// secretPath in the resolved project.
+func (p *Provider) updateSecret(projectID, secretPath, name, value string) error {
+	_, err := p.sdkClient.Secrets().Update(infisical.UpdateSecretOptions{
+		ProjectID:                projectID,
+		Environment:              p.apiScope.EnvironmentSlug,
+		SecretPath:               secretPath,
+		SecretKey:                name,
+		NewSecretValue:           value,
+		NewSkipMultilineEncoding: true,
+	})
+	metrics.ObserveAPICall(constants.ProviderName, updateSecretV3, err)
+	if err != nil {
+		return fmt.Errorf("failed to update secret %q: %w", name, err)
+	}
+	return nil
+}
+
+// resolveProjectID returns the project UUID for the configured slug, using the
+// process-wide cache to avoid an API call on every reconcile. The cached flag
+// reports whether the value came from the cache, so withResolvedProject can
+// distinguish a stale cached ID from a fresh one.
+func (p *Provider) resolveProjectID(ctx context.Context) (id string, cached bool, err error) {
+	key := p.projectCacheKey()
+	if v, ok := projectIDCache.Load(key); ok {
+		return v.(string), true, nil
+	}
+
+	id, err = p.fetchProjectID(ctx)
+	if err != nil {
+		return "", false, err
+	}
+	projectIDCache.Store(key, id)
+	return id, false, nil
+}
+
+// projectCacheKey returns the key identifying this store's project in the
+// process-wide cache: host, slug, and auth identity together, so entries are
+// never shared across instances or tenants.
+func (p *Provider) projectCacheKey() projectIDCacheKey {
+	return projectIDCacheKey{
+		host:     p.hostAPI,
+		slug:     p.apiScope.ProjectSlug,
+		identity: p.authIdentity,
+	}
+}
+
+// invalidateProjectID drops the cached slug -> ID entry so the next resolve
+// re-fetches it.
+func (p *Provider) invalidateProjectID() {
+	projectIDCache.Delete(p.projectCacheKey())
+}
+
+// withResolvedProject runs fn with the resolved project ID. If fn fails with a
+// 404 while using a *cached* ID, the project may have been deleted and possibly
+// recreated under the same slug with a new ID; the stale entry is dropped and
+// the ID re-resolved once. If the slug now maps to a (new) project, fn is
+// retried with it; if the slug no longer resolves, the re-resolution error is
+// returned so the caller sees a clear "no such project" message rather than the
+// raw write 404. A fresh ID is never retried, so a genuine 404 still surfaces.
+func (p *Provider) withResolvedProject(ctx context.Context, fn func(projectID string) error) error {
+	projectID, cached, err := p.resolveProjectID(ctx)
+	if err != nil {
+		return err
+	}
+
+	err = fn(projectID)
+	if err == nil || !cached || !isNotFoundError(err) {
+		return err
+	}
+
+	p.invalidateProjectID()
+	projectID, _, rerr := p.resolveProjectID(ctx)
+	if rerr != nil {
+		return rerr
+	}
+	return fn(projectID)
+}
+
+// projectLookupPaths are the "get project by slug" routes, tried in order. The
+// project-by-slug router moved mount points across Infisical versions: it lives
+// under /projects on current releases (e.g. v0.158.x and SaaS) and under
+// /workspace on some older ones. A 404 on one path falls through to the next;
+// any other failure is returned. The %s is the URL-escaped slug.
+var projectLookupPaths = []string{
+	"/v1/projects/slug/%s",
+	"/v1/workspace/slug/%s",
+}
+
+// fetchProjectID resolves the slug to a project UUID, authenticated with the
+// machine identity's access token. The go-sdk exposes no project lookup, so
+// this is a direct call.
+func (p *Provider) fetchProjectID(ctx context.Context) (string, error) {
+	ctx, cancel := context.WithTimeout(ctx, projectLookupTimeout)
+	defer cancel()
+
+	base := appendAPIEndpoint(p.hostAPI)
+	token := p.sdkClient.Auth().GetAccessToken()
+	client := p.lookupHTTPClient()
+	escapedSlug := url.PathEscape(p.apiScope.ProjectSlug)
+
+	var lastErr error
+	for _, tmpl := range projectLookupPaths {
+		id, notFound, err := p.lookupProjectID(ctx, client, token, base+fmt.Sprintf(tmpl, escapedSlug))
+		if err != nil {
+			lastErr = err
+			if notFound {
+				// Wrong route for this Infisical version; try the next one.
+				continue
+			}
+			return "", err
+		}
+		return id, nil
+	}
+	// Every known route returned 404: the slug does not resolve to a project on
+	// this instance (it may not exist or may have been deleted).
+	return "", fmt.Errorf("no Infisical project found for slug %q: %w", p.apiScope.ProjectSlug, lastErr)
+}
+
+// lookupProjectID performs one project-by-slug GET. notFound reports whether the
+// route returned 404 (so the caller can try an alternate path).
+func (p *Provider) lookupProjectID(ctx context.Context, client *http.Client, token, endpoint string) (id string, notFound bool, err error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
+	if err != nil {
+		return "", false, err
+	}
+	if token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+
+	resp, err := client.Do(req)
+	metrics.ObserveAPICall(constants.ProviderName, getWorkspaceBySlug, err)
+	if err != nil {
+		return "", false, fmt.Errorf("failed to resolve project id for slug %q: %w", p.apiScope.ProjectSlug, err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", false, err
+	}
+	if resp.StatusCode == http.StatusNotFound {
+		return "", true, fmt.Errorf("failed to resolve project id for slug %q: status 404: %s", p.apiScope.ProjectSlug, string(body))
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", false, fmt.Errorf("failed to resolve project id for slug %q: status %d: %s", p.apiScope.ProjectSlug, resp.StatusCode, string(body))
+	}
+
+	var project struct {
+		ID string `json:"id"`
+	}
+	if err := json.Unmarshal(body, &project); err != nil {
+		return "", false, fmt.Errorf("failed to decode project lookup response for slug %q: %w", p.apiScope.ProjectSlug, err)
+	}
+	if project.ID == "" {
+		return "", false, fmt.Errorf("project lookup for slug %q returned no id", p.apiScope.ProjectSlug)
+	}
+	return project.ID, false, nil
+}
+
+// lookupHTTPClient builds an HTTP client for the project lookup, honoring the
+// store's CA bundle when one is configured.
+func (p *Provider) lookupHTTPClient() *http.Client {
+	var transport *http.Transport
+	if t, ok := http.DefaultTransport.(*http.Transport); ok && t != nil {
+		transport = t.Clone()
+	} else {
+		transport = &http.Transport{}
+	}
+	if p.caCertificate != "" {
+		pool := x509.NewCertPool()
+		if pool.AppendCertsFromPEM([]byte(p.caCertificate)) {
+			transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
+		}
+	}
+	return &http.Client{Timeout: projectLookupTimeout, Transport: transport}
+}
+
+// appendAPIEndpoint mirrors the go-sdk's base-URL normalisation so the lookup
+// call hits the same /api root the SDK uses for secret operations.
+func appendAPIEndpoint(siteURL string) string {
+	switch {
+	case strings.HasSuffix(siteURL, "/api"):
+		return siteURL
+	case strings.HasSuffix(siteURL, "/"):
+		return siteURL + "api"
+	default:
+		return siteURL + "/api"
+	}
+}
+
+// objectKeyCount returns the number of top-level keys in a JSON object, or 1 for
+// any non-object so a scalar value is never treated as empty.
+func objectKeyCount(jsonData string) int {
+	result := gjson.Parse(jsonData)
+	if !result.IsObject() {
+		return 1
+	}
+	n := 0
+	result.ForEach(func(_, _ gjson.Result) bool {
+		n++
+		return true
+	})
+	return n
+}

+ 490 - 0
providers/v1/infisical/push_secret_test.go

@@ -0,0 +1,490 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package infisical
+
+import (
+	"context"
+	"encoding/json"
+	"encoding/pem"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/providers/v1/infisical/api"
+	"github.com/external-secrets/external-secrets/runtime/testing/fake"
+)
+
+const testProjectID = "11111111-1111-1111-1111-111111111111"
+
+// infisicalMock is a stateful fake of the Infisical raw-secret and project
+// endpoints the write path exercises. It records call counts so tests can
+// assert create-vs-update behavior and project-ID caching.
+type infisicalMock struct {
+	mu           sync.Mutex
+	secrets      map[string]string // name -> value
+	projectID    string            // ID returned by the slug lookup (mutable)
+	creates      int
+	updates      int
+	deletes      int
+	slugLookups  int
+	failSlug     bool // when true, the slug -> ID lookup returns 500
+	slugNotFound bool // when true, every slug -> ID route returns 404
+	// fallbackToWorkspace makes the /projects/slug route 404 and only the
+	// legacy /workspace/slug route resolve, exercising the resolver fallback.
+	fallbackToWorkspace bool
+}
+
+func newInfisicalMock() *infisicalMock {
+	return &infisicalMock{secrets: map[string]string{}, projectID: testProjectID}
+}
+
+func (m *infisicalMock) handler() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		m.mu.Lock()
+		defer m.mu.Unlock()
+
+		switch {
+		case r.Method == http.MethodGet && (strings.HasPrefix(r.URL.Path, "/api/v1/projects/slug/") || strings.HasPrefix(r.URL.Path, "/api/v1/workspace/slug/")):
+			m.slugLookups++
+			if m.failSlug {
+				writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "boom"})
+				return
+			}
+			if m.slugNotFound {
+				writeJSON(w, http.StatusNotFound, map[string]string{"message": "project not found"})
+				return
+			}
+			onProjects := strings.HasPrefix(r.URL.Path, "/api/v1/projects/slug/")
+			if m.fallbackToWorkspace && onProjects {
+				// Simulate an older Infisical that lacks the /projects route.
+				writeJSON(w, http.StatusNotFound, map[string]string{"message": "Route not found"})
+				return
+			}
+			writeJSON(w, http.StatusOK, map[string]string{"id": m.projectID})
+
+		case strings.HasPrefix(r.URL.Path, "/api/v3/secrets/raw/"):
+			name := strings.TrimPrefix(r.URL.Path, "/api/v3/secrets/raw/")
+			m.handleSecret(w, r, name)
+
+		default:
+			writeJSON(w, http.StatusNotFound, map[string]string{"message": "not found"})
+		}
+	}
+}
+
+func (m *infisicalMock) handleSecret(w http.ResponseWriter, r *http.Request, name string) {
+	switch r.Method {
+	case http.MethodGet:
+		// Reads resolve the project server-side from the slug, so they do not
+		// carry (or depend on) a workspace ID.
+		value, ok := m.secrets[name]
+		if !ok {
+			writeJSON(w, http.StatusNotFound, map[string]string{"message": "secret not found"})
+			return
+		}
+		writeSecret(w, name, value)
+	case http.MethodPost:
+		value, wsID := readSecretBody(r)
+		if m.staleWorkspace(w, wsID) {
+			return
+		}
+		m.creates++
+		m.secrets[name] = value
+		writeSecret(w, name, value)
+	case http.MethodPatch:
+		value, wsID := readSecretBody(r)
+		if m.staleWorkspace(w, wsID) {
+			return
+		}
+		m.updates++
+		m.secrets[name] = value
+		writeSecret(w, name, value)
+	case http.MethodDelete:
+		_, wsID := readSecretBody(r)
+		if m.staleWorkspace(w, wsID) {
+			return
+		}
+		if _, ok := m.secrets[name]; !ok {
+			writeJSON(w, http.StatusNotFound, map[string]string{"message": "secret not found"})
+			return
+		}
+		m.deletes++
+		value := m.secrets[name]
+		delete(m.secrets, name)
+		writeSecret(w, name, value)
+	default:
+		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"message": "method not allowed"})
+	}
+}
+
+// staleWorkspace mirrors the real API: a write whose workspaceId does not match
+// the current project returns 404, which the provider treats as a stale cached
+// project ID.
+func (m *infisicalMock) staleWorkspace(w http.ResponseWriter, wsID string) bool {
+	if wsID != m.projectID {
+		writeJSON(w, http.StatusNotFound, map[string]string{"message": "project not found"})
+		return true
+	}
+	return false
+}
+
+func readSecretBody(r *http.Request) (value, workspaceID string) {
+	var body struct {
+		SecretValue string `json:"secretValue"`
+		WorkspaceID string `json:"workspaceId"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&body)
+	return body.SecretValue, body.WorkspaceID
+}
+
+func writeSecret(w http.ResponseWriter, name, value string) {
+	writeJSON(w, http.StatusOK, map[string]any{
+		"secret": map[string]any{"secretKey": name, "secretValue": value},
+	})
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(body)
+}
+
+// newPushTestProvider wires a Provider to a fresh plain-HTTP mock server. Each
+// server gets a unique URL, so the package-level project-ID cache never
+// collides between tests.
+func newPushTestProvider(t *testing.T) (*Provider, *infisicalMock) {
+	return buildPushTestProvider(t, newInfisicalMock(), false)
+}
+
+func buildPushTestProvider(t *testing.T, mock *infisicalMock, useTLS bool) (*Provider, *infisicalMock) {
+	t.Helper()
+
+	var server *httptest.Server
+	if useTLS {
+		server = httptest.NewTLSServer(mock.handler())
+	} else {
+		server = httptest.NewServer(mock.handler())
+	}
+	t.Cleanup(server.Close)
+
+	sdkClient, cancel, err := api.NewAPIClient(server.URL, server.Certificate())
+	require.NoError(t, err)
+	t.Cleanup(cancel)
+
+	p := &Provider{
+		sdkClient: sdkClient,
+		apiScope: &ClientScope{
+			SecretPath:      "/",
+			ProjectSlug:     "first-project",
+			EnvironmentSlug: "dev",
+		},
+		hostAPI:      server.URL,
+		authIdentity: "test-identity",
+	}
+	if useTLS && server.Certificate() != nil {
+		p.caCertificate = string(pem.EncodeToMemory(&pem.Block{
+			Type:  "CERTIFICATE",
+			Bytes: server.Certificate().Raw,
+		}))
+	}
+	return p, mock
+}
+
+func TestPushSecret(t *testing.T) {
+	ctx := context.Background()
+
+	t.Run("creates a missing secret", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.Equal(t, 1, mock.creates)
+		assert.Equal(t, 0, mock.updates)
+		assert.Equal(t, "value", mock.secrets["remote"])
+	})
+
+	t.Run("updates an existing secret with a new value", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = "old"
+		secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("new")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, mock.creates)
+		assert.Equal(t, 1, mock.updates)
+		assert.Equal(t, "new", mock.secrets["remote"])
+	})
+
+	t.Run("is a no-op when the value already matches", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = "same"
+		secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("same")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, mock.creates)
+		assert.Equal(t, 0, mock.updates)
+	})
+
+	t.Run("pushes the whole secret as JSON when no secretKey is set", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		secret := &corev1.Secret{Data: map[string][]byte{"a": []byte("1"), "b": []byte("2")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.JSONEq(t, `{"a":"1","b":"2"}`, mock.secrets["remote"])
+	})
+
+	t.Run("merges into a JSON property", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = `{"existing":"keep"}`
+		secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("bar")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote", Property: "added"})
+		require.NoError(t, err)
+		assert.JSONEq(t, `{"existing":"keep","added":"bar"}`, mock.secrets["remote"])
+	})
+
+	t.Run("errors when the source key is absent", func(t *testing.T) {
+		p, _ := newPushTestProvider(t)
+		secret := &corev1.Secret{Data: map[string][]byte{"other": []byte("v")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "missing", RemoteKey: "remote"})
+		require.Error(t, err)
+	})
+
+	t.Run("errors when remoteKey is empty", func(t *testing.T) {
+		p, _ := newPushTestProvider(t)
+		secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("v")}}
+
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "key"})
+		require.ErrorIs(t, err, errMissingRemoteKey)
+	})
+}
+
+func TestDeleteSecret(t *testing.T) {
+	ctx := context.Background()
+
+	t.Run("deletes an existing secret", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = "value"
+
+		err := p.DeleteSecret(ctx, fake.PushSecretData{RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.Equal(t, 1, mock.deletes)
+		_, exists := mock.secrets["remote"]
+		assert.False(t, exists)
+	})
+
+	t.Run("is idempotent on a missing secret", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+
+		err := p.DeleteSecret(ctx, fake.PushSecretData{RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, mock.deletes)
+	})
+
+	t.Run("removes only the property, keeping the secret", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = `{"a":"1","b":"2"}`
+
+		err := p.DeleteSecret(ctx, fake.PushSecretData{RemoteKey: "remote", Property: "a"})
+		require.NoError(t, err)
+		assert.Equal(t, 0, mock.deletes)
+		assert.JSONEq(t, `{"b":"2"}`, mock.secrets["remote"])
+	})
+
+	t.Run("deletes the secret when the last property is removed", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = `{"a":"1"}`
+
+		err := p.DeleteSecret(ctx, fake.PushSecretData{RemoteKey: "remote", Property: "a"})
+		require.NoError(t, err)
+		assert.Equal(t, 1, mock.deletes)
+	})
+
+	t.Run("errors when the existing value is not a JSON object and a property is set", func(t *testing.T) {
+		for _, nonObject := range []string{"plain-string", `"valid-json-string"`, `[1,2,3]`} {
+			p, mock := newPushTestProvider(t)
+			mock.secrets["remote"] = nonObject
+
+			err := p.DeleteSecret(ctx, fake.PushSecretData{RemoteKey: "remote", Property: "key"})
+			require.Errorf(t, err, "expected error for value %q", nonObject)
+			assert.Contains(t, err.Error(), "not a JSON object")
+			assert.Equal(t, 0, mock.deletes)
+		}
+	})
+}
+
+func TestSecretExists(t *testing.T) {
+	ctx := context.Background()
+
+	t.Run("true when present", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = "value"
+
+		exists, err := p.SecretExists(ctx, fake.PushSecretData{RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.True(t, exists)
+	})
+
+	t.Run("false when absent", func(t *testing.T) {
+		p, _ := newPushTestProvider(t)
+
+		exists, err := p.SecretExists(ctx, fake.PushSecretData{RemoteKey: "remote"})
+		require.NoError(t, err)
+		assert.False(t, exists)
+	})
+
+	t.Run("property presence", func(t *testing.T) {
+		p, mock := newPushTestProvider(t)
+		mock.secrets["remote"] = `{"a":"1"}`
+
+		got, err := p.SecretExists(ctx, fake.PushSecretData{RemoteKey: "remote", Property: "a"})
+		require.NoError(t, err)
+		assert.True(t, got)
+
+		got, err = p.SecretExists(ctx, fake.PushSecretData{RemoteKey: "remote", Property: "missing"})
+		require.NoError(t, err)
+		assert.False(t, got)
+	})
+}
+
+func TestResolveProjectIDCaching(t *testing.T) {
+	ctx := context.Background()
+	p, mock := newPushTestProvider(t)
+	secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+
+	for i := range 3 {
+		err := p.PushSecret(ctx, secret, fake.PushSecretData{SecretKey: "key", RemoteKey: fmt.Sprintf("remote-%d", i)})
+		require.NoError(t, err)
+	}
+
+	// Three pushes, but the slug -> project ID lookup must happen only once.
+	assert.Equal(t, 1, mock.slugLookups)
+}
+
+func TestResolveProjectIDLookupFailure(t *testing.T) {
+	mock := newInfisicalMock()
+	mock.failSlug = true
+	p, mock := buildPushTestProvider(t, mock, false)
+	secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+
+	err := p.PushSecret(context.Background(), secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "project id")
+	// The push must abort before any write when the project cannot be resolved.
+	assert.Equal(t, 0, mock.creates)
+	assert.Equal(t, 0, mock.updates)
+}
+
+func TestPushSecretOverTLSWithCABundle(t *testing.T) {
+	p, mock := buildPushTestProvider(t, newInfisicalMock(), true)
+	// The CA bundle is required: the resolver's HTTP client must trust the
+	// test server's self-signed certificate to perform the slug lookup.
+	require.NotEmpty(t, p.caCertificate)
+	secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+
+	err := p.PushSecret(context.Background(), secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"})
+	require.NoError(t, err)
+	assert.Equal(t, 1, mock.slugLookups)
+	assert.Equal(t, "value", mock.secrets["remote"])
+}
+
+func TestResolveProjectIDFallsBackToWorkspaceRoute(t *testing.T) {
+	mock := newInfisicalMock()
+	mock.fallbackToWorkspace = true
+	p, mock := buildPushTestProvider(t, mock, false)
+	secret := &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+
+	err := p.PushSecret(context.Background(), secret, fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"})
+	require.NoError(t, err)
+	// Both the /projects (404) and the legacy /workspace (200) routes are hit.
+	assert.Equal(t, 2, mock.slugLookups)
+	assert.Equal(t, "value", mock.secrets["remote"])
+}
+
+func TestPushSecretReResolvesStaleProjectID(t *testing.T) {
+	ctx := context.Background()
+	mock := newInfisicalMock()
+	mock.projectID = "project-A"
+	p, mock := buildPushTestProvider(t, mock, false)
+	secret := func(v string) *corev1.Secret {
+		return &corev1.Secret{Data: map[string][]byte{"key": []byte(v)}}
+	}
+
+	// First push resolves and caches project-A.
+	require.NoError(t, p.PushSecret(ctx, secret("v1"), fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"}))
+	assert.Equal(t, "v1", mock.secrets["remote"])
+	assert.Equal(t, 1, mock.slugLookups)
+
+	// Simulate the project being deleted and recreated under the same slug with
+	// a new ID. The cached project-A is now stale.
+	mock.projectID = "project-B"
+
+	// Pushing a new secret must hit the stale 404, invalidate, re-resolve to
+	// project-B, and retry the write.
+	require.NoError(t, p.PushSecret(ctx, secret("v2"), fake.PushSecretData{SecretKey: "key", RemoteKey: "remote-after"}))
+	assert.Equal(t, "v2", mock.secrets["remote-after"])
+	assert.Equal(t, 2, mock.slugLookups, "expected one re-resolve after the stale 404")
+
+	// The freshly cached project-B is reused without another lookup.
+	require.NoError(t, p.PushSecret(ctx, secret("v3"), fake.PushSecretData{SecretKey: "key", RemoteKey: "remote-third"}))
+	assert.Equal(t, "v3", mock.secrets["remote-third"])
+	assert.Equal(t, 2, mock.slugLookups)
+}
+
+func TestPushSecretStaleProjectGoneReturnsError(t *testing.T) {
+	ctx := context.Background()
+	mock := newInfisicalMock()
+	mock.projectID = "project-A"
+	p, mock := buildPushTestProvider(t, mock, false)
+	secret := func(v string) *corev1.Secret {
+		return &corev1.Secret{Data: map[string][]byte{"key": []byte(v)}}
+	}
+
+	// Prime the cache with project-A.
+	require.NoError(t, p.PushSecret(ctx, secret("v1"), fake.PushSecretData{SecretKey: "key", RemoteKey: "remote"}))
+
+	// The project is deleted: writes 404 on the stale ID and the slug no longer
+	// resolves to any project.
+	mock.projectID = "gone"
+	mock.slugNotFound = true
+
+	// The write 404s, the cache is invalidated, the re-resolve also 404s, so a
+	// clear "no such project" error surfaces (not the raw write 404).
+	err := p.PushSecret(ctx, secret("v2"), fake.PushSecretData{SecretKey: "key", RemoteKey: "remote-new"})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "no Infisical project found")
+}
+
+func TestAppendAPIEndpoint(t *testing.T) {
+	assert.Equal(t, "https://h/api", appendAPIEndpoint("https://h"))
+	assert.Equal(t, "https://h/api", appendAPIEndpoint("https://h/"))
+	assert.Equal(t, "https://h/api", appendAPIEndpoint("https://h/api"))
+}
+
+var _ esv1.PushSecretData = fake.PushSecretData{}