瀏覽代碼

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 月之前
父節點
當前提交
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         |             |                             |
 | CyberArk Secrets Manager  |      x       |      x       |                      |                         |        x         |             |                             |
 | Delinea                   |      x       |              |                      |                         |        x         |             |                             |
 | Delinea                   |      x       |              |                      |                         |        x         |             |                             |
 | Beyondtrust               |      x       |              |                      |                         |        x         |             |                             |
 | Beyondtrust               |      x       |              |                      |                         |        x         |             |                             |
-| SecretServer              |      x       |              |                      |                         |        x         |             |                             |
+| SecretServer              |      x       |              |                      |                         |        x         |      x      |              x              |
 | Pulumi ESC                |      x       |              |                      |                         |        x         |             |                             |
 | Pulumi ESC                |      x       |              |                      |                         |        x         |             |                             |
 | Passbolt                  |      x       |              |                      |                         |        x         |             |                             |
 | Passbolt                  |      x       |              |                      |                         |        x         |             |                             |
 | Infisical                 |      x       |              |                      |            x            |        x         |             |                             |
 | Infisical                 |      x       |              |                      |            x            |        x         |             |                             |

+ 153 - 15
docs/provider/secretserver.md

@@ -35,18 +35,22 @@ spec:
 
 
 ### Referencing Secrets
 ### 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 />
 in your ExternalSecret configuration.<br />
 You can access nested values or arrays using [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md).
 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
     - secretKey: SecretServerValue  # Key in the Kubernetes Secret
       remoteRef:
       remoteRef:
         key: "/secretFolder/secretname"  # Path format: /<Folder>/<SecretName>
         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:
 #### Notes:
 
 
 The path must exactly match the folder and secret name in Secret-Server/Platform.
 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.
 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
 ### 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.
 See example JSON secret below.
 
 
 #### Examples
 #### 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
 #### 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"
 	"context"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
+	"fmt"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
+	"unicode/utf8"
 
 
 	"github.com/DelineaXPM/tss-sdk-go/v3/server"
 	"github.com/DelineaXPM/tss-sdk-go/v3/server"
 	"github.com/tidwall/gjson"
 	"github.com/tidwall/gjson"
@@ -29,32 +31,134 @@ import (
 
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	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"
+	"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 {
 type client struct {
 	api secretAPI
 	api secretAPI
 }
 }
 
 
 var _ esv1.SecretsClient = &client{}
 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.
 //     - 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.
 //     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) {
 func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
 	secret, err := c.getSecret(ctx, ref)
 	secret, err := c.getSecret(ctx, ref)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		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)
 	jsonStr, err := json.Marshal(secret)
 	if err != nil {
 	if err != nil {
@@ -66,45 +170,211 @@ func (c *client) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemot
 		return jsonStr, nil
 		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")
 	val := gjson.Get(string(jsonStr), "Items.0.ItemValue")
 	if val.Exists() && gjson.Valid(val.String()) {
 	if val.Exists() && gjson.Valid(val.String()) {
-		// extract specific value from data directly above using gjson
 		out := gjson.Get(val.String(), ref.Property)
 		out := gjson.Get(val.String(), ref.Property)
 		if out.Exists() {
 		if out.Exists() {
 			return []byte(out.String()), nil
 			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 {
 	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.
 // Validate not supported at this time.
@@ -120,7 +390,7 @@ func (c *client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRe
 		return nil, err
 		return nil, err
 	}
 	}
 	// Ensure secret has fields before indexing into them
 	// 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")
 		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
 	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) {
 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 {
 func (c *client) Close(context.Context) error {
 	return nil
 	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) {
 func (c *client) getSecret(_ context.Context, ref esv1.ExternalSecretDataRemoteRef) (*server.Secret, error) {
 	if ref.Version != "" {
 	if ref.Version != "" {
 		return nil, errors.New("specifying a version is not supported")
 		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 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
 			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/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/gjson v1.18.0
 	k8s.io/api v0.35.0
 	k8s.io/api v0.35.0
+	k8s.io/apiextensions-apiserver v0.35.0
 	k8s.io/apimachinery v0.35.0
 	k8s.io/apimachinery v0.35.0
 	sigs.k8s.io/controller-runtime v0.23.1
 	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/evanphx/json-patch.v4 v4.13.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v3 v3.0.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/client-go v0.35.0 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // 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).
 // Capabilities return the provider supported capabilities (ReadOnly, WriteOnly, ReadWrite).
 func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
 func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
-	return esv1.SecretStoreReadOnly
+	return esv1.SecretStoreReadWrite
 }
 }
 
 
 // NewClient creates a new secrets client based on provided store.
 // 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) {
 func TestNewClient(t *testing.T) {
 	userNameKey := "username"
 	userNameKey := "username"
 	userNameValue := "foo"
 	userNameValue := "foo"
-	passwordKey := "password"
+	passwordKey := passwordSlug
 	passwordValue := generateRandomString()
 	passwordValue := generateRandomString()
 	domain := "domain1"
 	domain := "domain1"
 
 
@@ -633,8 +633,8 @@ func TestCapabilities(t *testing.T) {
 	tests := map[string]struct {
 	tests := map[string]struct {
 		want esv1.SecretStoreCapabilities
 		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
 			// Edge: call Capabilities on nil Provider
 			var nilP *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
 // secretAPI represents the subset of the Secret Server API
 // which is supported by tss-sdk-go/v3.
 // which is supported by tss-sdk-go/v3.
 type secretAPI interface {
 type secretAPI interface {
+	// Secret retrieves a secret by its ID.
 	Secret(id int) (*server.Secret, error)
 	Secret(id int) (*server.Secret, error)
+	// Secrets searches for secrets by text and field name.
 	Secrets(searchText, field string) ([]server.Secret, error)
 	Secrets(searchText, field string) ([]server.Secret, error)
+	// SecretByPath retrieves a secret using its folder path.
 	SecretByPath(secretPath string) (*server.Secret, error)
 	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)
 }
 }