Browse Source

feat(providers): Implement PushSecret for Delinea Secret Server (#6031)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Marc Singer 2 weeks ago
parent
commit
93b430936a

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

@@ -119,7 +119,7 @@ The following table show the support for features across different providers.
 | CyberArk Secrets Manager  |      x       |      x       |                      |                         |        x         |             |                             |
 | Delinea                   |      x       |              |                      |                         |        x         |             |                             |
 | Beyondtrust               |      x       |              |                      |                         |        x         |             |                             |
-| SecretServer              |      x       |              |                      |                         |        x         |             |                             |
+| SecretServer              |      x       |              |                      |                         |        x         |      x      |              x              |
 | Pulumi ESC                |      x       |              |                      |                         |        x         |             |                             |
 | Passbolt                  |      x       |              |                      |                         |        x         |             |                             |
 | Infisical                 |      x       |              |                      |            x            |        x         |             |                             |

+ 153 - 15
docs/provider/secretserver.md

@@ -35,18 +35,22 @@ spec:
 
 ### Referencing Secrets
 
-Secrets may be referenced by:
->Secret ID<br />
-Secret Name<br />
-Secret Path (/FolderName/SecretName)<br />
+Secrets can be referenced using four different key formats in the `remoteRef.key` field:
 
-Please note if using the secret name or path,
-the name field must not contain spaces or control characters.<br />
-If multiple secrets are found, *`only the first found secret will be returned`*.
+| Format | Example | Description |
+|--------|---------|-------------|
+| Secret ID | `52622` | Numeric ID of the secret. Always unambiguous. |
+| Secret Name | `my-secret` | Name of the secret. If multiple secrets share the same name across different folders, the first match is returned. |
+| Secret Path | `/FolderName/SecretName` | Full folder path including the secret name. Uniquely identifies a secret across folders. |
+| Folder-scoped Name | `folderId:73/my-secret` | Name-based lookup scoped to a specific folder ID. Use this when multiple secrets share the same name in different folders and you need to target a specific one. |
 
-Please note: `Retrieving a specific version of a secret is not yet supported.`
+**Notes:**
 
-Note that because all Secret-Server/Platform secrets are JSON objects, you must specify the `remoteRef.property`
+- If using the secret name or path, the name must not contain spaces or control characters.
+- Retrieving a specific version of a secret is not yet supported.
+- The **folder-scoped name** format (`folderId:<id>/<name>`) is particularly important when using `PushSecret` with `deletionPolicy: Delete`, because the deletion and existence-check operations need to identify the correct secret without access to metadata. See [Pushing Secrets](#pushing-secrets) for details.
+
+Because all Secret-Server/Platform secrets are JSON objects, you must specify the `remoteRef.property`
 in your ExternalSecret configuration.<br />
 You can access nested values or arrays using [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md).
 
@@ -118,18 +122,18 @@ spec:
     - secretKey: SecretServerValue  # Key in the Kubernetes Secret
       remoteRef:
         key: "/secretFolder/secretname"  # Path format: /<Folder>/<SecretName>
-        property: ""                    # Optional: use gjson syntax to extract a specific field
+        property: ""                    # Optional: matched against field Slug/FieldName first, then gjson on Items.0.ItemValue as fallback
 ```
 
 #### Notes:
 
 The path must exactly match the folder and secret name in Secret-Server/Platform.
 If multiple secrets with the same name exist in different folders, the path helps to uniquely identify the correct one.
-You can still use property to extract values from JSON-formatted secrets or omit it to retrieve the entire secret.
+You can still use property to match fields by Slug/FieldName, extract values from JSON-formatted secrets via gjson, or omit it to retrieve the entire secret.
 
 ### Preparing your secret
-You can either retrieve your entire secret or you can use a JSON formatted string
-stored in your secret located at Items[0].ItemValue to retrieve a specific value.<br />
+You can either retrieve your entire secret, match a field by its Slug or FieldName, or use a JSON formatted string
+stored in your secret located at Items[0].ItemValue to retrieve a specific value using gjson syntax.<br />
 See example JSON secret below.
 
 #### Examples
@@ -195,9 +199,9 @@ returns: The entire secret in JSON format as displayed below
 }
 ```
 
-### Referencing Secrets in multiple Items secrets
+### Referencing Secrets by Field Name or Slug
 
-If there is more then one Item in the secret, it supports to retrieve them (all Item.\*.ItemValue) looking up by Item.\*.FieldName or Item.\*.Slug, instead of the above behaviour to use gjson only on the first item Items.0.ItemValue only.
+When `property` is set, the provider first tries to match it against each field's `Slug` or `FieldName` and returns the corresponding `ItemValue`. This works for secrets with any number of fields. If no field matches, it falls back to treating the first field's `ItemValue` as JSON and extracting the property using gjson syntax (supporting nested paths like `"books.1"`).
 
 #### Examples
 
@@ -272,3 +276,137 @@ returns: The entire secret in JSON format as displayed below
   ]
 }
 ```
+
+### Pushing Secrets
+
+The Delinea Secret-Server/Platform provider supports pushing secrets from Kubernetes back to your Secret Server instance using the `PushSecret` resource. You can both create new secrets and update existing ones.
+
+#### Remote Key Formats for PushSecret
+
+When using `PushSecret`, the `remoteRef.remoteKey` field determines how the provider identifies
+the target secret in Secret Server. The same key formats described in [Referencing Secrets](#referencing-secrets) apply here:
+
+| Format | Example | When to Use |
+|--------|---------|-------------|
+| Secret ID | `52622` | Updating an existing secret by its numeric ID. |
+| Secret Name | `my-secret` | Simple environments where secret names are unique across all folders. |
+| Secret Path | `/FolderName/SecretName` | When you know the full folder path of the secret. |
+| Folder-scoped Name | `folderId:73/my-secret` | **Recommended for new secrets.** Ensures all operations (push, delete, existence check) target the correct folder. |
+
+**Why the folder-scoped name format matters:**
+
+The `PushSecret` controller performs three distinct operations on secrets: **push** (create/update),
+**delete**, and **existence check**. While the push operation has access to the `metadata` field
+(which can carry a `folderId`), the delete and existence-check operations only receive the
+`remoteKey` and `property` — they do **not** have access to metadata.
+
+This means that if you use a plain secret name like `my-secret` and multiple secrets with that name
+exist in different folders, the delete and existence-check operations cannot distinguish between them
+and will act on the **first match** returned by the API.
+
+By using the `folderId:<id>/<name>` format (e.g., `folderId:73/my-secret`), the folder ID is
+encoded directly in the key and is available to **all** operations, ensuring consistent behavior.
+
+**Precedence rule:** If both a `folderId` in the `remoteKey` and a `folderId` in the metadata are
+specified, the value from the `remoteKey` takes precedence for lookups. The metadata `folderId` and
+`secretTemplateId` are still required when **creating** a new secret (they tell the API which folder
+and template to use for the new secret).
+
+#### Requirements for Creating New Secrets
+
+When creating a **new** secret in Secret Server, you must provide a `folderId` and a `secretTemplateId`. These are passed as `metadata` in the `PushSecret` spec:
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-secret-example
+spec:
+  refreshInterval: 1h
+  secretStoreRefs:
+    - name: secret-server-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-k8s-secret
+  data:
+    - match:
+        secretKey: username
+        remoteRef:
+          remoteKey: "folderId:73/my-new-secret" # Folder-scoped name ensures correct matching for all operations
+          property: username # Maps to the 'Username' field/slug in Secret Server
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          folderId: 73 # Required for new secrets: folder to create the secret in
+          secretTemplateId: 6098 # Required for new secrets: template to use
+    - match:
+        secretKey: password
+        remoteRef:
+          remoteKey: "folderId:73/my-new-secret"
+          property: password # Maps to the 'Password' field/slug in Secret Server
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          folderId: 73
+          secretTemplateId: 6098
+```
+
+> **Note:** The `folderId` in the `remoteKey` (`folderId:73/...`) is used when **looking up** the
+> secret (for push, delete, and existence checks). The `folderId` and `secretTemplateId` in
+> `metadata` are used when **creating** a new secret via the Secret Server API.
+
+#### Updating Existing Secrets
+
+When updating an existing secret, you do not strictly need the `folderId` or `secretTemplateId` metadata, as the provider will fetch the existing secret by its name or ID to update the corresponding fields.
+
+However, if multiple secrets share the same name across different folders, you should use either the
+`folderId:<id>/<name>` format, a path-based key, or a numeric ID to ensure the correct secret is
+updated. Using a plain name will update the **first match** returned by the API.
+
+#### Deletion Behavior
+
+The `PushSecret` resource allows you to configure what happens to the remote secret in Secret Server when the `PushSecret` itself is deleted, via the `PushSecret.spec.deletionPolicy` field. Supported values are:
+- `Retain`: (Default) The remote secret is left intact in Secret Server when the `PushSecret` is deleted.
+- `Delete`: The provider will attempt to delete the remote secret from Secret Server when the `PushSecret` is removed.
+
+When `Delete` is specified, the deletion operation is idempotent; if the secret has already been removed or cannot be found, the provider will safely ignore the error and proceed.
+
+**Important:** The deletion operation does **not** have access to `metadata`. If your Secret Server
+has multiple secrets with the same name in different folders and you use `deletionPolicy: Delete`,
+you **must** use a key format that uniquely identifies the secret — either `folderId:<id>/<name>`,
+a full path (`/Folder/SecretName`), or a numeric ID. Using a plain name risks deleting the wrong
+secret.
+
+#### Pushing Without a Property
+
+If you omit `property` from the `remoteRef`, the provider writes the value selected by `data.match.secretKey` (e.g., the content stored under the `config` key in your Kubernetes Secret) into the **first** field of the Secret Server secret. This is useful when your secret value is a single JSON payload that you want to store in a text field like `Data` or `Notes`.
+
+```yaml
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-secret-json-example
+spec:
+  refreshInterval: 1h
+  secretStoreRefs:
+    - name: secret-server-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-k8s-json-secret
+  data:
+    - match:
+        secretKey: config # The key in your k8s secret whose value will be pushed
+        remoteRef:
+          remoteKey: "folderId:73/my-new-json-secret"
+          # property is omitted: the value is stored in the first template field
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          folderId: 73
+          secretTemplateId: 6098
+```

+ 429 - 50
providers/v1/secretserver/client.go

@@ -20,8 +20,10 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"strconv"
 	"strings"
+	"unicode/utf8"
 
 	"github.com/DelineaXPM/tss-sdk-go/v3/server"
 	"github.com/tidwall/gjson"
@@ -29,32 +31,134 @@ import (
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
+	"github.com/external-secrets/external-secrets/runtime/esutils/metadata"
 )
 
+const (
+	// errMsgNoMatchingSecrets is returned by getSecretByName when a search returns zero results.
+	// This preserves backward compatibility with the original error message used
+	// before the PushSecret feature was added.
+	errMsgNoMatchingSecrets = "unable to retrieve secret at this time"
+	// errMsgNotFound is returned when a secret is not found in a specific folder.
+	errMsgNotFound = "not found"
+	// errMsgAmbiguousName is returned by lookupSecretStrict when a plain name
+	// matches multiple secrets across folders and no folder scope is provided.
+	errMsgAmbiguousName = "multiple secrets found with the same name across different folders; use the 'folderId:<id>/<name>' key format, a path-based key, or a numeric ID to disambiguate"
+
+	// folderPrefix is the prefix used to encode a folder ID in a remote key.
+	// Format: "folderId:<id>/<name>" (e.g. "folderId:73/my-secret").
+	folderPrefix = "folderId:"
+)
+
+// isNotFoundError checks if an error indicates a secret was not found.
+// The TSS SDK (v3) returns all errors as plain fmt.Errorf strings with format
+// "<StatusCode> <StatusText>: <body>" — no typed/sentinel errors.
+//
+// This function uses case-insensitive substring matching with explicit exclusions
+// for false-positive patterns produced by our own code (e.g. "not found in secret"
+// from updateSecret or "not found in secret template" from createSecret) and
+// non-404 HTTP errors that happen to contain "not found" in their body.
+func isNotFoundError(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := strings.ToLower(err.Error())
+
+	// Our own sentinel from getSecretByName / getSecretByNameStrict.
+	if strings.Contains(msg, errMsgNoMatchingSecrets) {
+		return true
+	}
+
+	// SDK HTTP 404 responses start with "404 ".
+	if strings.HasPrefix(msg, "404 ") {
+		return true
+	}
+
+	// Generic "not found" substring — but exclude false positives.
+	if strings.Contains(msg, errMsgNotFound) {
+		// Patterns like "field X not found in secret" or "field X not found in secret template"
+		// are field-level errors, not secret-not-found errors.
+		if strings.Contains(msg, "not found in secret") {
+			return false
+		}
+		// Exclude non-404 HTTP errors that happen to contain "not found" in the body
+		// (e.g. "401 Unauthorized: user not found"). The SDK formats all HTTP errors
+		// as "<StatusCode> <StatusText>: <body>", so any message starting with a
+		// 3-digit code followed by a space is an HTTP error.
+		if len(msg) >= 4 && msg[3] == ' ' && msg[0] >= '0' && msg[0] <= '9' && msg[1] >= '0' && msg[1] <= '9' && msg[2] >= '0' && msg[2] <= '9' {
+			return false // non-404 HTTP error (404 was already handled above)
+		}
+		return true
+	}
+
+	return false
+}
+
+// parseFolderPrefix extracts a folder ID and secret name from a key with the
+// format "folderId:<id>/<name>".  If the key does not match the prefix format,
+// it returns (0, key, false) so callers can fall through to other resolution
+// strategies.
+func parseFolderPrefix(key string) (folderID int, name string, hasFolderPrefix bool) {
+	if !strings.HasPrefix(key, folderPrefix) {
+		return 0, key, false
+	}
+
+	rest := strings.TrimPrefix(key, folderPrefix) // "<id>/<name>"
+
+	slashIdx := strings.Index(rest, "/") //nolint:modernize // Need index of first slash to separate folder ID from name
+	if slashIdx < 0 {
+		// "folderId:73" with no slash/name — treat as not having the prefix.
+		return 0, key, false
+	}
+
+	idStr := rest[:slashIdx]
+	secretName := rest[slashIdx+1:]
+
+	id, err := strconv.Atoi(idStr)
+	if err != nil || id <= 0 {
+		// Non-numeric or non-positive folder ID — treat as not having the prefix.
+		return 0, key, false
+	}
+
+	if secretName == "" {
+		// "folderId:73/" with empty name — treat as not having the prefix.
+		return 0, key, false
+	}
+
+	return id, secretName, true
+}
+
+// PushSecretMetadataSpec contains metadata information for pushing secrets to Delinea Secret Server.
+type PushSecretMetadataSpec struct {
+	FolderID         int `json:"folderId"`
+	SecretTemplateID int `json:"secretTemplateId"`
+}
+
 type client struct {
 	api secretAPI
 }
 
 var _ esv1.SecretsClient = &client{}
 
-// GetSecret supports two types:
-//  1. Get the secrets using the secret ID in ref.key i.e. key: 53974
-//  2. Get the secret using the secret "name" i.e. key: "secretNameHere"
+// GetSecret supports several lookup modes:
+//  1. Get the secret using the secret ID in ref.Key (e.g. key: 53974).
+//  2. Get the secret using the secret "name" (e.g. key: "secretNameHere").
 //     - Secret names must not contain spaces.
-//     - If using the secret "name" and multiple secrets are found ...
+//     - If using the secret "name" and multiple secrets are found,
 //     the first secret in the array will be the secret returned.
-//  3. get the full secret as json-encoded value
-//     by leaving the ref.Property empty.
-//  4. get a specific value by using a key from the json formatted secret in Items.0.ItemValue.
-//     Nested values are supported by specifying a gjson expression
+//  3. Get the full secret as a JSON-encoded value by leaving ref.Property empty.
+//  4. Get a specific value by using a key from the JSON-formatted secret in
+//     Items.0.ItemValue via gjson (supports nested paths like "server.1").
+//     If the first field's ItemValue is not valid JSON or the gjson path
+//     does not match, fall back to matching ref.Property against each field's
+//     Slug or FieldName (useful for multi-field secrets).
 func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
 	secret, err := c.getSecret(ctx, ref)
 	if err != nil {
 		return nil, err
 	}
-	// Return nil if secret contains no fields
-	if secret.Fields == nil {
-		return nil, nil
+	if len(secret.Fields) == 0 {
+		return nil, errors.New("secret contains no fields")
 	}
 	jsonStr, err := json.Marshal(secret)
 	if err != nil {
@@ -66,45 +170,211 @@ func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemot
 		return jsonStr, nil
 	}
 
-	// extract first "field" i.e. Items.0.ItemValue, data from secret using gjson
+	// Primary path: extract ref.Property from the first field's ItemValue via gjson.
+	// This preserves backward compatibility with the original single-field JSON blob pattern.
 	val := gjson.Get(string(jsonStr), "Items.0.ItemValue")
 	if val.Exists() && gjson.Valid(val.String()) {
-		// extract specific value from data directly above using gjson
 		out := gjson.Get(val.String(), ref.Property)
 		if out.Exists() {
 			return []byte(out.String()), nil
 		}
 	}
 
-	// More general case Fields is an array in DelineaXPM/tss-sdk-go/v3/server
-	// https://github.com/DelineaXPM/tss-sdk-go/blob/571e5674a8103031ad6f873453db27959ec1ca67/server/secret.go#L23
-	secretMap := make(map[string]string)
+	// Fallback: match ref.Property against field Slug or FieldName.
+	// This supports multi-field secrets where fields are accessed by name.
 	for index := range secret.Fields {
-		secretMap[secret.Fields[index].FieldName] = secret.Fields[index].ItemValue
-		secretMap[secret.Fields[index].Slug] = secret.Fields[index].ItemValue
+		if secret.Fields[index].Slug == ref.Property || secret.Fields[index].FieldName == ref.Property {
+			return []byte(secret.Fields[index].ItemValue), nil
+		}
 	}
 
-	out, ok := secretMap[ref.Property]
-	if !ok {
-		return nil, esv1.NoSecretError{}
+	return nil, esv1.NoSecretError{}
+}
+
+// PushSecret creates or updates a secret in Delinea Secret Server.
+func (c *client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
+	if data.GetRemoteKey() == "" {
+		return errors.New("remote key must be defined")
 	}
 
-	return []byte(out), nil
+	value, err := esutils.ExtractSecretData(data, secret)
+	if err != nil {
+		return fmt.Errorf("failed to extract secret data: %w", err)
+	}
+
+	if !utf8.Valid(value) {
+		return errors.New("secret value is not valid UTF-8")
+	}
+
+	meta, err := metadata.ParseMetadataParameters[PushSecretMetadataSpec](data.GetMetadata())
+	if err != nil {
+		return fmt.Errorf("failed to parse metadata: %w", err)
+	}
+
+	// Resolve the effective folder ID for both lookups AND creation.
+	// A folderId encoded in the remoteKey takes precedence over metadata
+	// (delete and existence-check never see metadata, so the prefix must win
+	// to keep lookup and creation consistent).
+	folderID := 0
+	if meta != nil {
+		folderID = meta.Spec.FolderID
+	}
+	if prefixFolderID, _, ok := parseFolderPrefix(data.GetRemoteKey()); ok {
+		folderID = prefixFolderID
+	}
+
+	existingSecret, err := c.findExistingSecret(ctx, data.GetRemoteKey(), folderID)
+	if err != nil {
+		if !isNotFoundError(err) {
+			return fmt.Errorf("failed to get secret: %w", err)
+		}
+		existingSecret = nil
+	}
+
+	if existingSecret != nil {
+		// Update existing secret
+		return c.updateSecret(existingSecret, data.GetProperty(), string(value))
+	}
+
+	if meta == nil || meta.Spec.SecretTemplateID <= 0 {
+		return errors.New("folderId and secretTemplateId must be provided in metadata to create a new secret")
+	}
+
+	// Use the effective folderID (prefix-overridden or metadata-supplied) for creation.
+	if folderID <= 0 {
+		return errors.New("folderId and secretTemplateId must be provided in metadata to create a new secret")
+	}
+
+	createSpec := meta.Spec
+	createSpec.FolderID = folderID
+	return c.createSecret(data.GetRemoteKey(), data.GetProperty(), string(value), createSpec)
 }
 
-// PushSecret not supported at this time.
-func (c *client) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1.PushSecretData) error {
-	return errors.New("pushing secrets is not supported by Secret Server at this time")
+// updateSecret updates an existing secret in Delinea Secret Server.
+func (c *client) updateSecret(secret *server.Secret, property, value string) error {
+	if property == "" {
+		// If property is empty, put the JSON value in the first field, matching GetSecretMap logic
+		if len(secret.Fields) > 0 {
+			secret.Fields[0].ItemValue = value
+		} else {
+			return errors.New("secret has no fields to update")
+		}
+	} else {
+		found := false
+		for i, field := range secret.Fields {
+			if field.Slug == property || field.FieldName == property {
+				secret.Fields[i].ItemValue = value
+				found = true
+				break
+			}
+		}
+		if !found {
+			return fmt.Errorf("field %s not found in secret", property)
+		}
+	}
+
+	_, err := c.api.UpdateSecret(*secret)
+	if err != nil {
+		return fmt.Errorf("failed to update secret: %w", err)
+	}
+	return nil
 }
 
-// DeleteSecret not supported at this time.
-func (c *client) DeleteSecret(_ context.Context, _ esv1.PushSecretRemoteRef) error {
-	return errors.New("deleting secrets is not supported by Secret Server at this time")
+// createSecret creates a new secret in Delinea Secret Server.
+// Only the targeted field is populated; other required template fields
+// may cause an API error.
+func (c *client) createSecret(name, property, value string, meta PushSecretMetadataSpec) error {
+	template, err := c.api.SecretTemplate(meta.SecretTemplateID)
+	if err != nil {
+		return fmt.Errorf("failed to get secret template: %w", err)
+	}
+
+	if strings.HasSuffix(name, "/") {
+		return fmt.Errorf("invalid secret name %q: name must not be empty or end with a trailing slash", name)
+	}
+
+	// Strip the "folderId:<id>/" prefix if present so the secret is created
+	// with just the plain name.  The folder is already specified in meta.FolderID.
+	if _, stripped, ok := parseFolderPrefix(name); ok {
+		name = stripped
+		// After prefix stripping, the name should be a simple name (no slashes).
+		// The folderId format is "folderId:<id>/<name>", not "folderId:<id>/<path>".
+		if strings.Contains(name, "/") {
+			return fmt.Errorf("invalid secret name %q in folderId prefix: name must not contain path separators", name)
+		}
+	}
+
+	// For path-based keys (e.g. "/Folder/SubFolder/SecretName"), extract the
+	// basename. The folder structure is controlled by meta.FolderID.
+	normalizedName := strings.TrimPrefix(name, "/")
+	if strings.Contains(normalizedName, "/") {
+		parts := strings.Split(normalizedName, "/")
+		normalizedName = parts[len(parts)-1]
+	}
+
+	newSecret := server.Secret{
+		Name:             normalizedName,
+		FolderID:         meta.FolderID,
+		SecretTemplateID: meta.SecretTemplateID,
+		Fields:           make([]server.SecretField, 0),
+	}
+
+	if property == "" {
+		// No property specified: use the first template field.
+		if len(template.Fields) == 0 {
+			return errors.New("secret template has no fields")
+		}
+		newSecret.Fields = append(newSecret.Fields, server.SecretField{
+			FieldID:   template.Fields[0].SecretTemplateFieldID,
+			ItemValue: value,
+		})
+	} else {
+		// Use the field matching the specified property.
+		fieldID, found := findTemplateFieldID(template, property)
+		if !found {
+			return fmt.Errorf("field %s not found in secret template", property)
+		}
+		newSecret.Fields = append(newSecret.Fields, server.SecretField{
+			FieldID:   fieldID,
+			ItemValue: value,
+		})
+	}
+
+	_, err = c.api.CreateSecret(newSecret)
+	if err != nil {
+		return fmt.Errorf("failed to create secret: %w", err)
+	}
+	return nil
+}
+
+// DeleteSecret deletes a secret in Delinea Secret Server.
+func (c *client) DeleteSecret(_ context.Context, ref esv1.PushSecretRemoteRef) error {
+	secret, err := c.lookupSecretStrict(ref.GetRemoteKey())
+	if err != nil {
+		// If already deleted/not found, ignore
+		if isNotFoundError(err) {
+			return nil
+		}
+		return fmt.Errorf("failed to get secret for deletion: %w", err)
+	}
+
+	err = c.api.DeleteSecret(secret.ID)
+	if err != nil {
+		return fmt.Errorf("failed to delete secret: %w", err)
+	}
+	return nil
 }
 
-// SecretExists not supported at this time.
-func (c *client) SecretExists(_ context.Context, _ esv1.PushSecretRemoteRef) (bool, error) {
-	return false, errors.New("not implemented")
+// SecretExists checks if a secret exists in Delinea Secret Server.
+func (c *client) SecretExists(_ context.Context, ref esv1.PushSecretRemoteRef) (bool, error) {
+	_, err := c.lookupSecretStrict(ref.GetRemoteKey())
+	if err != nil {
+		if isNotFoundError(err) {
+			return false, nil
+		}
+		return false, fmt.Errorf("failed to check if secret exists: %w", err)
+	}
+	return true, nil
 }
 
 // Validate not supported at this time.
@@ -120,7 +390,7 @@ func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRe
 		return nil, err
 	}
 	// Ensure secret has fields before indexing into them
-	if secret.Fields == nil || len(secret.Fields) == 0 {
+	if len(secret.Fields) == 0 {
 		return nil, errors.New("secret contains no fields")
 	}
 
@@ -143,43 +413,152 @@ func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRe
 	return data, nil
 }
 
-// GetAllSecrets not supported at this time.
+// GetAllSecrets is not supported. The tss-sdk-go v3 SDK search is hard-capped
+// at 30 results with no pagination, no tag filtering, and no folder enumeration.
 func (c *client) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
-	return nil, errors.New("getting all secrets is not supported by Delinea Secret Server at this time")
+	return nil, errors.New("getting all secrets is not supported by Delinea Secret Server")
 }
 
 func (c *client) Close(context.Context) error {
 	return nil
 }
 
-// getSecret retrieves the secret referenced by ref from the Vault API.
+// getSecret retrieves the secret referenced by ref from the Secret Server API.
 func (c *client) getSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) (*server.Secret, error) {
 	if ref.Version != "" {
 		return nil, errors.New("specifying a version is not supported")
 	}
 
-	// If the ref.Key looks like a full path (starts with "/"), fetch by path.
-	// Example: "/Folder/Subfolder/SecretName"
-	if strings.HasPrefix(ref.Key, "/") {
-		s, err := c.api.SecretByPath(ref.Key)
-		if err != nil {
+	return c.lookupSecret(ref.Key, 0)
+}
+
+// findExistingSecret looks up a secret for PushSecret's find-or-create logic.
+// Unlike getSecret (used for reads), this refuses ambiguous plain-name matches
+// when no folder scope is available, matching the safety behavior of DeleteSecret
+// and SecretExists.
+func (c *client) findExistingSecret(_ context.Context, key string, folderID int) (*server.Secret, error) {
+	// When a folder scope is available (either from prefix or metadata),
+	// the lookup is unambiguous — use the regular (non-strict) resolver.
+	if folderID > 0 {
+		return c.lookupSecret(key, folderID)
+	}
+	// No folder scope: use strict lookup to reject ambiguous plain names.
+	return c.lookupSecretStrict(key)
+}
+
+// lookupSecret resolves a secret by path ("/..."), numeric ID, folder-scoped
+// name ("folderId:<id>/<name>"), or plain name.
+// The folderID scopes name-based lookups (0 = any folder).  A folder prefix
+// encoded in the key takes precedence over the folderID argument.
+func (c *client) lookupSecret(key string, folderID int) (*server.Secret, error) {
+	// 1. Folder-scoped prefix: "folderId:<id>/<name>" — override folderID and
+	//    resolve by name within the specified folder.
+	if prefixFolderID, name, ok := parseFolderPrefix(key); ok {
+		return c.getSecretByName(name, prefixFolderID)
+	}
+
+	// 2. Path-based key: fully qualified, no disambiguation needed.
+	if strings.HasPrefix(key, "/") {
+		return c.api.SecretByPath(key)
+	}
+
+	// 3. Numeric key: treat as ID first; fall back to name-based lookup so that
+	//    secrets whose name happens to be a numeric string can still be resolved.
+	if id, err := strconv.Atoi(key); err == nil {
+		secret, err := c.api.Secret(id)
+		if err == nil && secret != nil {
+			return secret, nil
+		}
+		if !isNotFoundError(err) {
 			return nil, err
 		}
-		return s, nil
 	}
 
-	// Otherwise try converting it to an ID
-	id, err := strconv.Atoi(ref.Key)
-	if err != nil {
-		s, err := c.api.Secrets(ref.Key, "Name")
-		if err != nil {
+	return c.getSecretByName(key, folderID)
+}
+
+// lookupSecretStrict resolves a secret like lookupSecret but refuses to
+// silently pick the first match when a plain name (no folderId prefix, no
+// path, no numeric ID) matches more than one secret across folders.
+// This is used by destructive operations (DeleteSecret) and existence checks
+// (SecretExists) that must not accidentally act on the wrong secret.
+func (c *client) lookupSecretStrict(key string) (*server.Secret, error) {
+	// 1. Folder-scoped prefix: unambiguous — delegate directly.
+	if prefixFolderID, name, ok := parseFolderPrefix(key); ok {
+		return c.getSecretByName(name, prefixFolderID)
+	}
+
+	// 2. Path-based key: unambiguous.
+	if strings.HasPrefix(key, "/") {
+		return c.api.SecretByPath(key)
+	}
+
+	// 3. Numeric key: try as ID first; fall back to name-based lookup.
+	if id, err := strconv.Atoi(key); err == nil {
+		secret, err := c.api.Secret(id)
+		if err == nil && secret != nil {
+			return secret, nil
+		}
+		if !isNotFoundError(err) {
 			return nil, err
 		}
-		if len(s) == 0 {
-			return nil, errors.New("unable to retrieve secret at this time")
+	}
+
+	// 4. Plain name: reject if ambiguous (multiple matches without folder scope).
+	return c.getSecretByNameStrict(key)
+}
+
+// getSecretByNameStrict searches for a secret by name and returns an error if
+// multiple secrets share the same name across different folders.
+func (c *client) getSecretByNameStrict(name string) (*server.Secret, error) {
+	secrets, err := c.api.Secrets(name, "Name")
+	if err != nil {
+		return nil, err
+	}
+	if len(secrets) == 0 {
+		return nil, errors.New(errMsgNoMatchingSecrets)
+	}
+	if len(secrets) > 1 {
+		return nil, errors.New(errMsgAmbiguousName)
+	}
+	return &secrets[0], nil
+}
+
+func (c *client) getSecretByName(name string, folderID int) (*server.Secret, error) {
+	secrets, err := c.api.Secrets(name, "Name")
+	if err != nil {
+		return nil, err
+	}
+	if len(secrets) == 0 {
+		return nil, errors.New(errMsgNoMatchingSecrets)
+	}
+
+	// No folder constraint: return the first match.
+	if folderID == 0 {
+		return &secrets[0], nil
+	}
+
+	// Find the first secret matching the requested folder.
+	for i, s := range secrets {
+		if s.FolderID == folderID {
+			return &secrets[i], nil
 		}
+	}
+	return nil, errors.New(errMsgNotFound)
+}
 
-		return &s[0], nil
+func findTemplateFieldID(template *server.SecretTemplate, property string) (int, bool) {
+	fieldID, found := template.FieldSlugToId(property)
+	if found {
+		return fieldID, true
 	}
-	return c.api.Secret(id)
+
+	// fallback check if they used name instead of slug
+	for _, f := range template.Fields {
+		if f.Name == property || f.FieldSlugName == property {
+			return f.SecretTemplateFieldID, true
+		}
+	}
+
+	return 0, false
 }

File diff suppressed because it is too large
+ 1360 - 51
providers/v1/secretserver/client_test.go


+ 1 - 1
providers/v1/secretserver/go.mod

@@ -9,6 +9,7 @@ require (
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
 	k8s.io/api v0.35.0
+	k8s.io/apiextensions-apiserver v0.35.0
 	k8s.io/apimachinery v0.35.0
 	sigs.k8s.io/controller-runtime v0.23.1
 )
@@ -86,7 +87,6 @@ require (
 	gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/apiextensions-apiserver v0.35.0 // indirect
 	k8s.io/client-go v0.35.0 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect

+ 1 - 1
providers/v1/secretserver/provider.go

@@ -52,7 +52,7 @@ var _ esv1.Provider = &Provider{}
 
 // Capabilities return the provider supported capabilities (ReadOnly, WriteOnly, ReadWrite).
 func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
-	return esv1.SecretStoreReadOnly
+	return esv1.SecretStoreReadWrite
 }
 
 // NewClient creates a new secrets client based on provided store.

+ 4 - 6
providers/v1/secretserver/provider_test.go

@@ -167,7 +167,7 @@ func TestValidateStore(t *testing.T) {
 func TestNewClient(t *testing.T) {
 	userNameKey := "username"
 	userNameValue := "foo"
-	passwordKey := "password"
+	passwordKey := passwordSlug
 	passwordValue := generateRandomString()
 	domain := "domain1"
 
@@ -633,8 +633,8 @@ func TestCapabilities(t *testing.T) {
 	tests := map[string]struct {
 		want esv1.SecretStoreCapabilities
 	}{
-		"returns ReadOnly capability": {
-			want: esv1.SecretStoreReadOnly,
+		"returns ReadWrite capability": {
+			want: esv1.SecretStoreReadWrite,
 		},
 	}
 
@@ -646,9 +646,7 @@ func TestCapabilities(t *testing.T) {
 
 			// Edge: call Capabilities on nil Provider
 			var nilP *Provider
-			if nilP != nil {
-				assert.Equal(t, esv1.SecretStoreReadOnly, nilP.Capabilities())
-			}
+			assert.Equal(t, esv1.SecretStoreReadWrite, nilP.Capabilities())
 		})
 	}
 }

+ 11 - 0
providers/v1/secretserver/secret_api.go

@@ -23,7 +23,18 @@ import (
 // secretAPI represents the subset of the Secret Server API
 // which is supported by tss-sdk-go/v3.
 type secretAPI interface {
+	// Secret retrieves a secret by its ID.
 	Secret(id int) (*server.Secret, error)
+	// Secrets searches for secrets by text and field name.
 	Secrets(searchText, field string) ([]server.Secret, error)
+	// SecretByPath retrieves a secret using its folder path.
 	SecretByPath(secretPath string) (*server.Secret, error)
+	// CreateSecret creates a new secret in Secret Server.
+	CreateSecret(secret server.Secret) (*server.Secret, error)
+	// UpdateSecret updates an existing secret in Secret Server.
+	UpdateSecret(secret server.Secret) (*server.Secret, error)
+	// DeleteSecret deletes a secret by its ID.
+	DeleteSecret(id int) error
+	// SecretTemplate retrieves a secret template by its ID.
+	SecretTemplate(id int) (*server.SecretTemplate, error)
 }