Browse Source

Add support for Delinea DevOps Secrets Vault (#2415)

* Add support for Delinea DevOps Secrets Vault

Closes #1709.

Signed-off-by: Michael Sauter <michael.sauter@boehringer-ingelheim.com>

* fix: remove merge conflict

Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>

* Improve documentation

Signed-off-by: Michael Sauter <michael.sauter@boehringer-ingelheim.com>

---------

Signed-off-by: Michael Sauter <michael.sauter@boehringer-ingelheim.com>
Signed-off-by: Moritz Johner <beller.moritz@googlemail.com>
Co-authored-by: Moritz Johner <beller.moritz@googlemail.com>
Co-authored-by: Moritz Johner <moolen@users.noreply.github.com>
Michael Sauter 2 years ago
parent
commit
bdf437c2e1

+ 51 - 0
apis/externalsecrets/v1beta1/secretsstore_delinea_types.go

@@ -0,0 +1,51 @@
+/*
+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
+
+    http://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 v1beta1
+
+import esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+
+type DelineaProviderSecretRef struct {
+
+	// Value can be specified directly to set a value without using a secret.
+	// +optional
+	Value string `json:"value,omitempty"`
+
+	// SecretRef references a key in a secret that will be used as value.
+	// +optional
+	SecretRef *esmeta.SecretKeySelector `json:"secretRef,omitempty"`
+}
+
+// See https://github.com/DelineaXPM/dsv-sdk-go/blob/main/vault/vault.go.
+type DelineaProvider struct {
+
+	// ClientID is the non-secret part of the credential.
+	ClientID *DelineaProviderSecretRef `json:"clientId"`
+
+	// ClientSecret is the secret part of the credential.
+	ClientSecret *DelineaProviderSecretRef `json:"clientSecret"`
+
+	// Tenant is the chosen hostname / site name.
+	Tenant string `json:"tenant"`
+
+	// URLTemplate
+	// If unset, defaults to "https://%s.secretsvaultcloud.%s/v1/%s%s".
+	// +optional
+	URLTemplate string `json:"urlTemplate,omitempty"`
+
+	// TLD is based on the server location that was chosen during provisioning.
+	// If unset, defaults to "com".
+	// +optional
+	TLD string `json:"tld,omitempty"`
+}

+ 5 - 0
apis/externalsecrets/v1beta1/secretstore_types.go

@@ -136,6 +136,11 @@ type SecretStoreProvider struct {
 	// Conjur configures this store to sync secrets using conjur provider
 	// +optional
 	Conjur *ConjurProvider `json:"conjur,omitempty"`
+
+	// Delinea DevOps Secrets Vault
+	// https://docs.delinea.com/online-help/products/devops-secrets-vault/current
+	// +optional
+	Delinea *DelineaProvider `json:"delinea,omitempty"`
 }
 
 type CAProviderType string

+ 50 - 0
apis/externalsecrets/v1beta1/zz_generated.deepcopy.go

@@ -702,6 +702,51 @@ func (in *ConjurProvider) DeepCopy() *ConjurProvider {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DelineaProvider) DeepCopyInto(out *DelineaProvider) {
+	*out = *in
+	if in.ClientID != nil {
+		in, out := &in.ClientID, &out.ClientID
+		*out = new(DelineaProviderSecretRef)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.ClientSecret != nil {
+		in, out := &in.ClientSecret, &out.ClientSecret
+		*out = new(DelineaProviderSecretRef)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DelineaProvider.
+func (in *DelineaProvider) DeepCopy() *DelineaProvider {
+	if in == nil {
+		return nil
+	}
+	out := new(DelineaProvider)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DelineaProviderSecretRef) DeepCopyInto(out *DelineaProviderSecretRef) {
+	*out = *in
+	if in.SecretRef != nil {
+		in, out := &in.SecretRef, &out.SecretRef
+		*out = new(metav1.SecretKeySelector)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DelineaProviderSecretRef.
+func (in *DelineaProviderSecretRef) DeepCopy() *DelineaProviderSecretRef {
+	if in == nil {
+		return nil
+	}
+	out := new(DelineaProviderSecretRef)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *DopplerAuth) DeepCopyInto(out *DopplerAuth) {
 	*out = *in
 	in.SecretRef.DeepCopyInto(&out.SecretRef)
@@ -1861,6 +1906,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) {
 		*out = new(ConjurProvider)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.Delinea != nil {
+		in, out := &in.Delinea, &out.Delinea
+		*out = new(DelineaProvider)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretStoreProvider.

+ 72 - 0
config/crds/bases/external-secrets.io_clustersecretstores.yaml

@@ -2262,6 +2262,78 @@ spec:
                     - auth
                     - url
                     type: object
+                  delinea:
+                    description: Delinea DevOps Secrets Vault https://docs.delinea.com/online-help/products/devops-secrets-vault/current
+                    properties:
+                      clientId:
+                        description: ClientID is the non-secret part of the credential.
+                        properties:
+                          secretRef:
+                            description: SecretRef references a key in a secret that
+                              will be used as value.
+                            properties:
+                              key:
+                                description: The key of the entry in the Secret resource's
+                                  `data` field to be used. Some instances of this
+                                  field may be defaulted, in others it may be required.
+                                type: string
+                              name:
+                                description: The name of the Secret resource being
+                                  referred to.
+                                type: string
+                              namespace:
+                                description: Namespace of the resource being referred
+                                  to. Ignored if referent is not cluster-scoped. cluster-scoped
+                                  defaults to the namespace of the referent.
+                                type: string
+                            type: object
+                          value:
+                            description: Value can be specified directly to set a
+                              value without using a secret.
+                            type: string
+                        type: object
+                      clientSecret:
+                        description: ClientSecret is the secret part of the credential.
+                        properties:
+                          secretRef:
+                            description: SecretRef references a key in a secret that
+                              will be used as value.
+                            properties:
+                              key:
+                                description: The key of the entry in the Secret resource's
+                                  `data` field to be used. Some instances of this
+                                  field may be defaulted, in others it may be required.
+                                type: string
+                              name:
+                                description: The name of the Secret resource being
+                                  referred to.
+                                type: string
+                              namespace:
+                                description: Namespace of the resource being referred
+                                  to. Ignored if referent is not cluster-scoped. cluster-scoped
+                                  defaults to the namespace of the referent.
+                                type: string
+                            type: object
+                          value:
+                            description: Value can be specified directly to set a
+                              value without using a secret.
+                            type: string
+                        type: object
+                      tenant:
+                        description: Tenant is the chosen hostname / site name.
+                        type: string
+                      tld:
+                        description: TLD is based on the server location that was
+                          chosen during provisioning. If unset, defaults to "com".
+                        type: string
+                      urlTemplate:
+                        description: URLTemplate If unset, defaults to "https://%s.secretsvaultcloud.%s/v1/%s%s".
+                        type: string
+                    required:
+                    - clientId
+                    - clientSecret
+                    - tenant
+                    type: object
                   doppler:
                     description: Doppler configures this store to sync secrets using
                       the Doppler provider

+ 72 - 0
config/crds/bases/external-secrets.io_secretstores.yaml

@@ -2262,6 +2262,78 @@ spec:
                     - auth
                     - url
                     type: object
+                  delinea:
+                    description: Delinea DevOps Secrets Vault https://docs.delinea.com/online-help/products/devops-secrets-vault/current
+                    properties:
+                      clientId:
+                        description: ClientID is the non-secret part of the credential.
+                        properties:
+                          secretRef:
+                            description: SecretRef references a key in a secret that
+                              will be used as value.
+                            properties:
+                              key:
+                                description: The key of the entry in the Secret resource's
+                                  `data` field to be used. Some instances of this
+                                  field may be defaulted, in others it may be required.
+                                type: string
+                              name:
+                                description: The name of the Secret resource being
+                                  referred to.
+                                type: string
+                              namespace:
+                                description: Namespace of the resource being referred
+                                  to. Ignored if referent is not cluster-scoped. cluster-scoped
+                                  defaults to the namespace of the referent.
+                                type: string
+                            type: object
+                          value:
+                            description: Value can be specified directly to set a
+                              value without using a secret.
+                            type: string
+                        type: object
+                      clientSecret:
+                        description: ClientSecret is the secret part of the credential.
+                        properties:
+                          secretRef:
+                            description: SecretRef references a key in a secret that
+                              will be used as value.
+                            properties:
+                              key:
+                                description: The key of the entry in the Secret resource's
+                                  `data` field to be used. Some instances of this
+                                  field may be defaulted, in others it may be required.
+                                type: string
+                              name:
+                                description: The name of the Secret resource being
+                                  referred to.
+                                type: string
+                              namespace:
+                                description: Namespace of the resource being referred
+                                  to. Ignored if referent is not cluster-scoped. cluster-scoped
+                                  defaults to the namespace of the referent.
+                                type: string
+                            type: object
+                          value:
+                            description: Value can be specified directly to set a
+                              value without using a secret.
+                            type: string
+                        type: object
+                      tenant:
+                        description: Tenant is the chosen hostname / site name.
+                        type: string
+                      tld:
+                        description: TLD is based on the server location that was
+                          chosen during provisioning. If unset, defaults to "com".
+                        type: string
+                      urlTemplate:
+                        description: URLTemplate If unset, defaults to "https://%s.secretsvaultcloud.%s/v1/%s%s".
+                        type: string
+                    required:
+                    - clientId
+                    - clientSecret
+                    - tenant
+                    type: object
                   doppler:
                     description: Doppler configures this store to sync secrets using
                       the Doppler provider

+ 114 - 0
deploy/crds/bundle.yaml

@@ -2109,6 +2109,63 @@ spec:
                         - auth
                         - url
                       type: object
+                    delinea:
+                      description: Delinea DevOps Secrets Vault https://docs.delinea.com/online-help/products/devops-secrets-vault/current
+                      properties:
+                        clientId:
+                          description: ClientID is the non-secret part of the credential.
+                          properties:
+                            secretRef:
+                              description: SecretRef references a key in a secret that will be used as value.
+                              properties:
+                                key:
+                                  description: The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be defaulted, in others it may be required.
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being referred to.
+                                  type: string
+                                namespace:
+                                  description: Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults to the namespace of the referent.
+                                  type: string
+                              type: object
+                            value:
+                              description: Value can be specified directly to set a value without using a secret.
+                              type: string
+                          type: object
+                        clientSecret:
+                          description: ClientSecret is the secret part of the credential.
+                          properties:
+                            secretRef:
+                              description: SecretRef references a key in a secret that will be used as value.
+                              properties:
+                                key:
+                                  description: The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be defaulted, in others it may be required.
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being referred to.
+                                  type: string
+                                namespace:
+                                  description: Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults to the namespace of the referent.
+                                  type: string
+                              type: object
+                            value:
+                              description: Value can be specified directly to set a value without using a secret.
+                              type: string
+                          type: object
+                        tenant:
+                          description: Tenant is the chosen hostname / site name.
+                          type: string
+                        tld:
+                          description: TLD is based on the server location that was chosen during provisioning. If unset, defaults to "com".
+                          type: string
+                        urlTemplate:
+                          description: URLTemplate If unset, defaults to "https://%s.secretsvaultcloud.%s/v1/%s%s".
+                          type: string
+                      required:
+                        - clientId
+                        - clientSecret
+                        - tenant
+                      type: object
                     doppler:
                       description: Doppler configures this store to sync secrets using the Doppler provider
                       properties:
@@ -5755,6 +5812,63 @@ spec:
                         - auth
                         - url
                       type: object
+                    delinea:
+                      description: Delinea DevOps Secrets Vault https://docs.delinea.com/online-help/products/devops-secrets-vault/current
+                      properties:
+                        clientId:
+                          description: ClientID is the non-secret part of the credential.
+                          properties:
+                            secretRef:
+                              description: SecretRef references a key in a secret that will be used as value.
+                              properties:
+                                key:
+                                  description: The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be defaulted, in others it may be required.
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being referred to.
+                                  type: string
+                                namespace:
+                                  description: Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults to the namespace of the referent.
+                                  type: string
+                              type: object
+                            value:
+                              description: Value can be specified directly to set a value without using a secret.
+                              type: string
+                          type: object
+                        clientSecret:
+                          description: ClientSecret is the secret part of the credential.
+                          properties:
+                            secretRef:
+                              description: SecretRef references a key in a secret that will be used as value.
+                              properties:
+                                key:
+                                  description: The key of the entry in the Secret resource's `data` field to be used. Some instances of this field may be defaulted, in others it may be required.
+                                  type: string
+                                name:
+                                  description: The name of the Secret resource being referred to.
+                                  type: string
+                                namespace:
+                                  description: Namespace of the resource being referred to. Ignored if referent is not cluster-scoped. cluster-scoped defaults to the namespace of the referent.
+                                  type: string
+                              type: object
+                            value:
+                              description: Value can be specified directly to set a value without using a secret.
+                              type: string
+                          type: object
+                        tenant:
+                          description: Tenant is the chosen hostname / site name.
+                          type: string
+                        tld:
+                          description: TLD is based on the server location that was chosen during provisioning. If unset, defaults to "com".
+                          type: string
+                        urlTemplate:
+                          description: URLTemplate If unset, defaults to "https://%s.secretsvaultcloud.%s/v1/%s%s".
+                          type: string
+                      required:
+                        - clientId
+                        - clientSecret
+                        - tenant
+                      type: object
                     doppler:
                       description: Doppler configures this store to sync secrets using the Doppler provider
                       properties:

+ 141 - 0
docs/api/spec.md

@@ -1775,6 +1775,132 @@ ConjurAuth
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1beta1.DelineaProvider">DelineaProvider
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1beta1.SecretStoreProvider">SecretStoreProvider</a>)
+</p>
+<p>
+<p>See <a href="https://github.com/DelineaXPM/dsv-sdk-go/blob/main/vault/vault.go">https://github.com/DelineaXPM/dsv-sdk-go/blob/main/vault/vault.go</a>.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>clientId</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.DelineaProviderSecretRef">
+DelineaProviderSecretRef
+</a>
+</em>
+</td>
+<td>
+<p>ClientID is the non-secret part of the credential.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>clientSecret</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.DelineaProviderSecretRef">
+DelineaProviderSecretRef
+</a>
+</em>
+</td>
+<td>
+<p>ClientSecret is the secret part of the credential.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>tenant</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Tenant is the chosen hostname / site name.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>urlTemplate</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>URLTemplate
+If unset, defaults to &ldquo;https://%s.secretsvaultcloud.%s/v1/%s%s&rdquo;.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>tld</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>TLD is based on the server location that was chosen during provisioning.
+If unset, defaults to &ldquo;com&rdquo;.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1beta1.DelineaProviderSecretRef">DelineaProviderSecretRef
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1beta1.DelineaProvider">DelineaProvider</a>)
+</p>
+<p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>value</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Value can be specified directly to set a value without using a secret.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>secretRef</code></br>
+<em>
+<a href="https://pkg.go.dev/github.com/external-secrets/external-secrets/apis/meta/v1#SecretKeySelector">
+External Secrets meta/v1.SecretKeySelector
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>SecretRef references a key in a secret that will be used as value.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1beta1.DopplerAuth">DopplerAuth
 </h3>
 <p>
@@ -4865,6 +4991,21 @@ ConjurProvider
 <p>Conjur configures this store to sync secrets using conjur provider</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>delinea</code></br>
+<em>
+<a href="#external-secrets.io/v1beta1.DelineaProvider">
+DelineaProvider
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Delinea DevOps Secrets Vault
+<a href="https://docs.delinea.com/online-help/products/devops-secrets-vault/current">https://docs.delinea.com/online-help/products/devops-secrets-vault/current</a></p>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1beta1.SecretStoreRef">SecretStoreRef

+ 2 - 0
docs/introduction/stability-support.md

@@ -52,6 +52,7 @@ The following table describes the stability level of each provider and who's res
 | [Keeper Security](https://www.keepersecurity.com/)                                                         |   alpha   |                                                                                                                                              [@ppodevlab](https://github.com/ppodevlab) |
 | [Scaleway](https://external-secrets.io/latest/provider/scaleway)                                           |   alpha   |                                                                                                                                                   [@azert9](https://github.com/azert9/) |
 | [Conjur](https://external-secrets.io/latest/provider/conjur)                                               |   alpha   |                                                                                                                                 [@davidh-cyberark](https://github.com/davidh-cyberark/) |
+| [Delinea](https://external-secrets.io/latest/provider/delinea)                                             |   alpha   |                                                                                                                                     [@michaelsauter](https://github.com/michaelsauter/) |
 
 ## Provider Feature Support
 
@@ -78,6 +79,7 @@ The following table show the support for features across different providers.
 | Keeper Security           |      x       |              |                      |                         |        x         |      x      |                             |
 | Scaleway                  |      x       |      x       |                      |                         |        x         |      x      |              x              |
 | Conjur                    |              |              |                      |                         |        x         |             |                             |
+| Delinea                   |      x       |              |                      |                         |        x         |             |                             |
 
 ## Support Policy
 

+ 56 - 0
docs/provider/delinea.md

@@ -0,0 +1,56 @@
+## Delinea DevOps Secrets Vault
+
+External Secrets Operator integrates with [Delinea DevOps Secrets Vault](https://docs.delinea.com/online-help/products/devops-secrets-vault/current).
+
+Please note that the [Delinea Secret Server](https://delinea.com/products/secret-server) product is NOT in scope of this integration.
+
+### Creating a SecretStore
+
+You need client ID, client secret and tenant to authenticate with DSV.
+Both client ID and client secret can be specified either directly in the config, or by referencing a kubernetes secret.
+
+To acquire client ID and client secret, refer to the  [policy management](https://docs.delinea.com/dsv/current/tutorials/policy.md) and [client management](https://docs.delinea.com/dsv/current/usage/cli-ref/client.md) documentation.
+
+```yaml
+apiVersion: external-secrets.io/v1beta1
+kind: SecretStore
+metadata:
+  name: secret-store
+spec:
+  provider:
+    delinea:
+      tenant: <TENANT>
+      tld: <TLD>
+      clientId:
+        value: <CLIENT_ID>
+      clientSecret:
+        secretRef:
+          name: <NAME_OF_KUBE_SECRET>
+          key: <KEY_IN_KUBE_SECRET>
+```
+
+Both `clientId` and `clientSecret` can either be specified directly via the `value` field or can reference a kubernetes secret.
+
+The `tenant` field must correspond to the host name / site name of your DevOps vault. If you selected a region other than the US you must also specify the TLD, e.g. `tld: eu`.
+
+If required, the URL template (`urlTemplate`) can be customized as well.
+
+### Referencing Secrets
+
+Secrets can be referenced by path. Getting a specific version of a secret is not yet supported.
+
+```yaml
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+    name: secret
+spec:
+    refreshInterval: 20s
+    secretStoreRef:
+        kind: SecretStore
+        name: secret-store
+    data:
+      - secretKey: <KEY_IN_KUBE_SECRET>
+        remoteRef:
+          key: <SECRET_PATH>
+```

+ 1 - 0
e2e/go.mod

@@ -39,6 +39,7 @@ require (
 	cloud.google.com/go/secretmanager v1.11.1
 	github.com/Azure/azure-sdk-for-go v68.0.0+incompatible
 	github.com/Azure/go-autorest/autorest/azure/auth v0.5.12
+	github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.0
 	github.com/akeylesslabs/akeyless-go-cloud-id v0.3.4
 	github.com/akeylesslabs/akeyless-go/v3 v3.3.12
 	github.com/aliyun/alibaba-cloud-sdk-go v1.62.271

+ 2 - 0
e2e/go.sum

@@ -77,6 +77,8 @@ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUM
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.0 h1:+XXJ43iH4js8LIBr4MUGq1J09ycivNkTNhtn4mFyhY8=
+github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.0/go.mod h1:NTdQaRBIRZ/8gIzs010CS/u69aVSmqD1zbESW25y2cE=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/akeylesslabs/akeyless-go-cloud-id v0.3.4 h1:vTckjyBhHOBiOWSC/oaEU2Oo4OH5eAlQiwKu2RMxsFg=
 github.com/akeylesslabs/akeyless-go-cloud-id v0.3.4/go.mod h1:As/RomC2w/fa3y+yHRlVHPmkbP+zrKBFRow41y5dk+E=

+ 5 - 0
e2e/run.sh

@@ -79,6 +79,11 @@ kubectl run --rm \
   --env="SCALEWAY_PROJECT_ID=${SCALEWAY_PROJECT_ID:-}" \
   --env="SCALEWAY_ACCESS_KEY=${SCALEWAY_ACCESS_KEY:-}" \
   --env="SCALEWAY_SECRET_KEY=${SCALEWAY_SECRET_KEY:-}" \
+  --env="DELINEA_TLD=${DELINEA_TLD:-}" \
+  --env="DELINEA_URL_TEMPLATE=${DELINEA_URL_TEMPLATE:-}" \
+  --env="DELINEA_TENANT=${DELINEA_TENANT:-}" \
+  --env="DELINEA_CLIENT_ID=${DELINEA_CLIENT_ID:-}" \
+  --env="DELINEA_CLIENT_SECRET=${DELINEA_CLIENT_SECRET:-}" \
   --env="VERSION=${VERSION}" \
   --env="TEST_SUITES=${TEST_SUITES}" \
   --overrides='{ "apiVersion": "v1", "spec":{"serviceAccountName": "external-secrets-e2e"}}' \

+ 1 - 1
e2e/suites/provider/cases/common/common.go

@@ -512,7 +512,7 @@ func DockerJSONConfig(f *framework.Framework) (string, func(*framework.TestCase)
 	return "[common] should sync docker configurated json secrets with template simple", func(tc *framework.TestCase) {
 		cloudSecretName := fmt.Sprintf("%s-%s", f.Namespace.Name, dockerConfigExampleName)
 		cloudRemoteRefKey := f.MakeRemoteRefKey(cloudSecretName)
-		dockerconfig := `{"auths":{"https://index.docker.io/v1/": {"auth": "c3R...zE2"}}}`
+		dockerconfig := `{"auths":{"https://index.docker.io/v1/":{"auth":"c3R...zE2"}}}`
 		cloudSecretValue := fmt.Sprintf(`{"dockerconfig": %s}`, dockerconfig)
 		tc.Secrets = map[string]framework.SecretEntry{
 			cloudRemoteRefKey: {Value: cloudSecretValue},

+ 47 - 0
e2e/suites/provider/cases/delinea/config.go

@@ -0,0 +1,47 @@
+package delinea
+
+import (
+	"fmt"
+	"os"
+)
+
+type config struct {
+	tld          string
+	urlTemplate  string
+	tenant       string
+	clientID     string
+	clientSecret string
+}
+
+func loadConfigFromEnv() (*config, error) {
+	var cfg config
+	var err error
+
+	// Optional settings
+	cfg.tld, _ = getEnv("DELINEA_TLD")
+	cfg.urlTemplate, _ = getEnv("DELINEA_URL_TEMPLATE")
+
+	// Required settings
+	cfg.tenant, err = getEnv("DELINEA_TENANT")
+	if err != nil {
+		return nil, err
+	}
+	cfg.clientID, err = getEnv("DELINEA_CLIENT_ID")
+	if err != nil {
+		return nil, err
+	}
+	cfg.clientSecret, err = getEnv("DELINEA_CLIENT_SECRET")
+	if err != nil {
+		return nil, err
+	}
+
+	return &cfg, nil
+}
+
+func getEnv(name string) (string, error) {
+	value, ok := os.LookupEnv(name)
+	if !ok {
+		return "", fmt.Errorf("environment variable %q is not set", name)
+	}
+	return value, nil
+}

+ 115 - 0
e2e/suites/provider/cases/delinea/delinea.go

@@ -0,0 +1,115 @@
+package delinea
+
+import (
+	"context"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/onsi/ginkgo/v2"
+	"github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+var _ = ginkgo.Describe("[delinea]", ginkgo.Label("delinea"), func() {
+
+	f := framework.New("eso-delinea")
+
+	// Initialization is deferred so that assertions work.
+	provider := &secretStoreProvider{}
+
+	ginkgo.BeforeEach(func() {
+
+		cfg, err := loadConfigFromEnv()
+		gomega.Expect(err).ToNot(gomega.HaveOccurred())
+
+		provider.init(cfg)
+
+		createResources(context.Background(), f, cfg)
+	})
+
+	ginkgo.DescribeTable("sync secrets", framework.TableFunc(f, provider),
+
+		ginkgo.Entry(common.JSONDataWithProperty(f)),
+		ginkgo.Entry(common.JSONDataWithoutTargetName(f)),
+		ginkgo.Entry(common.JSONDataWithTemplate(f)),
+		ginkgo.Entry(common.JSONDataWithTemplateFromLiteral(f)),
+		ginkgo.Entry(common.TemplateFromConfigmaps(f)),
+		ginkgo.Entry(common.JSONDataFromSync(f)),
+		ginkgo.Entry(common.JSONDataFromRewrite(f)),
+		ginkgo.Entry(common.NestedJSONWithGJSON(f)),
+		ginkgo.Entry(common.DockerJSONConfig(f)),
+		ginkgo.Entry(common.DataPropertyDockerconfigJSON(f)),
+		ginkgo.Entry(common.SSHKeySyncDataProperty(f)),
+		ginkgo.Entry(common.DecodingPolicySync(f)),
+
+		// V1Alpha1 is not supported.
+		// ginkgo.Entry(common.SyncV1Alpha1(f)),
+
+		// Non-JSON values are not supported by DSV.
+		// ginkgo.Entry(common.SimpleDataSync(f)),
+		// ginkgo.Entry(common.SyncWithoutTargetName(f)),
+		// ginkgo.Entry(common.SSHKeySync(f)),
+		// ginkgo.Entry(common.DeletionPolicyDelete(f)),
+
+		// FindByName is not supported.
+		// ginkgo.Entry(common.FindByName(f)),
+		// ginkgo.Entry(common.FindByNameAndRewrite(f)),
+		// ginkgo.Entry(common.FindByNameWithPath(f)),
+
+		// FindByTag is not supported.
+		// ginkgo.Entry(common.FindByTag(f)),
+		// ginkgo.Entry(common.FindByTagWithPath(f)),
+	)
+})
+
+func createResources(ctx context.Context, f *framework.Framework, cfg *config) {
+
+	secretName := "delinea-credential"
+	secretKey := "client-secret"
+
+	// Creating a secret to hold the Delinea client secret.
+	secretSpec := v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      secretName,
+			Namespace: f.Namespace.Name,
+		},
+		StringData: map[string]string{
+			secretKey: cfg.clientSecret,
+		},
+	}
+
+	err := f.CRClient.Create(ctx, &secretSpec)
+	gomega.Expect(err).ToNot(gomega.HaveOccurred())
+
+	// Creating SecretStore.
+	secretStoreSpec := esv1beta1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      f.Namespace.Name,
+			Namespace: f.Namespace.Name,
+		},
+		Spec: esv1beta1.SecretStoreSpec{
+			Provider: &esv1beta1.SecretStoreProvider{
+				Delinea: &esv1beta1.DelineaProvider{
+					Tenant:      cfg.tenant,
+					TLD:         cfg.tld,
+					URLTemplate: cfg.urlTemplate,
+					ClientID: &esv1beta1.DelineaProviderSecretRef{
+						Value: cfg.clientID,
+					},
+					ClientSecret: &esv1beta1.DelineaProviderSecretRef{
+						SecretRef: &esmeta.SecretKeySelector{
+							Name: secretName,
+							Key:  secretKey,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	err = f.CRClient.Create(ctx, &secretStoreSpec)
+	gomega.Expect(err).ToNot(gomega.HaveOccurred())
+}

+ 47 - 0
e2e/suites/provider/cases/delinea/provider.go

@@ -0,0 +1,47 @@
+package delinea
+
+import (
+	"encoding/json"
+
+	"github.com/DelineaXPM/dsv-sdk-go/v2/vault"
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/onsi/gomega"
+)
+
+type secretStoreProvider struct {
+	api *vault.Vault
+	cfg *config
+}
+
+func (p *secretStoreProvider) init(cfg *config) {
+
+	p.cfg = cfg
+
+	dsvClient, err := vault.New(vault.Configuration{
+		Credentials: vault.ClientCredential{
+			ClientID:     cfg.clientID,
+			ClientSecret: cfg.clientSecret,
+		},
+		Tenant:      cfg.tenant,
+		URLTemplate: cfg.urlTemplate,
+		TLD:         cfg.tld,
+	})
+	gomega.Expect(err).ToNot(gomega.HaveOccurred())
+
+	p.api = dsvClient
+}
+
+func (p *secretStoreProvider) CreateSecret(key string, val framework.SecretEntry) {
+	var data map[string]interface{}
+	err := json.Unmarshal([]byte(val.Value), &data)
+	gomega.Expect(err).ToNot(gomega.HaveOccurred())
+	_, err = p.api.CreateSecret(key, &vault.SecretCreateRequest{
+		Data: data,
+	})
+	gomega.Expect(err).ToNot(gomega.HaveOccurred())
+}
+
+func (p *secretStoreProvider) DeleteSecret(key string) {
+	err := p.api.DeleteSecret(key)
+	gomega.Expect(err).ToNot(gomega.HaveOccurred())
+}

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

@@ -19,6 +19,7 @@ import (
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/parameterstore"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/secretsmanager"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/azure"
+	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/delinea"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/gcp"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/kubernetes"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/scaleway"

+ 1 - 0
go.mod

@@ -63,6 +63,7 @@ require github.com/1Password/connect-sdk-go v1.5.1
 require (
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.1
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0
+	github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.0
 	github.com/akeylesslabs/akeyless-go/v3 v3.3.12
 	github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.4
 	github.com/alibabacloud-go/kms-20160120/v3 v3.0.2

+ 2 - 0
go.sum

@@ -87,6 +87,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkM
 github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.0 h1:+XXJ43iH4js8LIBr4MUGq1J09ycivNkTNhtn4mFyhY8=
+github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.0/go.mod h1:NTdQaRBIRZ/8gIzs010CS/u69aVSmqD1zbESW25y2cE=
 github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM=
 github.com/IBM/go-sdk-core/v5 v5.13.4 h1:kJvBNQOwhFRkXCPapjNvKVC7n7n2vd1Nr6uUtDZGcfo=
 github.com/IBM/go-sdk-core/v5 v5.13.4/go.mod h1:gKRSB+YyKsGlRQW7v5frlLbue5afulSvrRa4O26o4MM=

+ 1 - 0
hack/api-docs/mkdocs.yml

@@ -106,6 +106,7 @@ nav:
     - Doppler: provider/doppler.md
     - Keeper Security: provider/keeper-security.md
     - Scaleway: provider/scaleway.md
+    - Delinea: provider/delinea.md
   - Examples:
     - FluxCD: examples/gitops-using-fluxcd.md
     - Anchore Engine: examples/anchore-engine-credentials.md

+ 148 - 0
pkg/provider/delinea/client.go

@@ -0,0 +1,148 @@
+/*
+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
+
+	http://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 delinea
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+
+	"github.com/DelineaXPM/dsv-sdk-go/v2/vault"
+	"github.com/tidwall/gjson"
+
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+)
+
+const (
+	errSecretKeyFmt  = "cannot find secret data for key: %q"
+	errUnexpectedKey = "unexpected key in data: %s"
+	errSecretFormat  = "secret data for property %s not in expected format: %s"
+)
+
+type client struct {
+	api secretAPI
+}
+
+var _ esv1beta1.SecretsClient = &client{}
+
+// GetSecret supports two types:
+//  1. get the full secret as json-encoded value
+//     by leaving the ref.Property empty.
+//  2. get a key from the secret.
+//     Nested values are supported by specifying a gjson expression
+func (c *client) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	secret, err := c.getSecret(ctx, ref)
+	if err != nil {
+		return nil, err
+	}
+	// Return nil if secret value is null
+	if secret.Data == nil {
+		return nil, nil
+	}
+	jsonStr, err := json.Marshal(secret.Data)
+	if err != nil {
+		return nil, err
+	}
+	// return raw json if no property is defined
+	if ref.Property == "" {
+		return jsonStr, nil
+	}
+	// extract key from secret using gjson
+	val := gjson.Get(string(jsonStr), ref.Property)
+	if !val.Exists() {
+		return nil, esv1beta1.NoSecretError{}
+	}
+	return []byte(val.String()), nil
+}
+
+func (c *client) PushSecret(_ context.Context, _ []byte, _ esv1beta1.PushRemoteRef) error {
+	return errors.New("pushing secrets is not supported by Delinea DevOps Secrets Vault")
+}
+
+func (c *client) DeleteSecret(_ context.Context, _ esv1beta1.PushRemoteRef) error {
+	return errors.New("deleting secrets is not supported by Delinea DevOps Secrets Vault")
+}
+
+func (c *client) Validate() (esv1beta1.ValidationResult, error) {
+	return esv1beta1.ValidationResultReady, nil
+}
+
+// GetSecret gets the full secret as json-encoded value.
+func (c *client) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	secret, err := c.getSecret(ctx, ref)
+	if err != nil {
+		return nil, err
+	}
+	byteMap := make(map[string][]byte, len(secret.Data))
+	for k := range secret.Data {
+		byteMap[k], err = getTypedKey(secret.Data, k)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return byteMap, nil
+}
+
+// GetAllSecrets lists secrets matching the given criteria and return their latest versions.
+func (c *client) GetAllSecrets(_ context.Context, _ esv1beta1.ExternalSecretFind) (map[string][]byte, error) {
+	return nil, errors.New("getting all secrets is not supported by Delinea DevOps Secrets Vault")
+}
+
+func (c *client) Close(context.Context) error {
+	return nil
+}
+
+// getSecret retrieves the secret referenced by ref from the Vault API.
+func (c *client) getSecret(_ context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (*vault.Secret, error) {
+	if ref.Version != "" {
+		return nil, errors.New("specifying a version is not yet supported")
+	}
+	return c.api.Secret(ref.Key)
+}
+
+// getTypedKey is copied from pkg/provider/vault/vault.go.
+func getTypedKey(data map[string]interface{}, key string) ([]byte, error) {
+	v, ok := data[key]
+	if !ok {
+		return nil, fmt.Errorf(errUnexpectedKey, key)
+	}
+	switch t := v.(type) {
+	case string:
+		return []byte(t), nil
+	case map[string]interface{}:
+		return json.Marshal(t)
+	case []string:
+		return []byte(strings.Join(t, "\n")), nil
+	case []byte:
+		return t, nil
+	// also covers int and float32 due to json.Marshal
+	case float64:
+		return []byte(strconv.FormatFloat(t, 'f', -1, 64)), nil
+	case json.Number:
+		return []byte(t.String()), nil
+	case []interface{}:
+		return json.Marshal(t)
+	case bool:
+		return []byte(strconv.FormatBool(t)), nil
+	case nil:
+		return []byte(nil), nil
+	default:
+		return nil, fmt.Errorf(errSecretFormat, key, reflect.TypeOf(t))
+	}
+}

+ 117 - 0
pkg/provider/delinea/client_test.go

@@ -0,0 +1,117 @@
+/*
+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
+
+	http://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 delinea
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/DelineaXPM/dsv-sdk-go/v2/vault"
+	"github.com/stretchr/testify/assert"
+
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+)
+
+type fakeAPI struct {
+	secrets []*vault.Secret
+}
+
+// createVaultSecret assembles a vault.Secret.
+// vault.Secret has unexported nested types, and is therefore quite
+// tricky from outside the vault package. This function facilitates easy setup.
+func createVaultSecret(path string, data map[string]interface{}) *vault.Secret {
+	s := &vault.Secret{}
+	s.Path = path
+	s.Data = data
+	return s
+}
+
+// Secret returns secret matching path.
+func (f *fakeAPI) Secret(path string) (*vault.Secret, error) {
+	for _, s := range f.secrets {
+		if s.Path == path {
+			return s, nil
+		}
+	}
+	return nil, errors.New("not found")
+}
+
+func newTestClient() esv1beta1.SecretsClient {
+	return &client{
+		api: &fakeAPI{
+			secrets: []*vault.Secret{
+				createVaultSecret("a", map[string]interface{}{}),
+				createVaultSecret("b", map[string]interface{}{
+					"hello": "world",
+				}),
+				createVaultSecret("c", map[string]interface{}{
+					"foo": map[string]string{"bar": "baz"},
+				}),
+			},
+		},
+	}
+}
+
+func TestGetSecret(t *testing.T) {
+	ctx := context.Background()
+	c := newTestClient()
+
+	testCases := map[string]struct {
+		ref  esv1beta1.ExternalSecretDataRemoteRef
+		want []byte
+		err  error
+	}{
+		"querying for the key returns the map": {
+			ref: esv1beta1.ExternalSecretDataRemoteRef{
+				Key: "b",
+			},
+			want: []byte(`{"hello":"world"}`),
+		},
+		"querying for the key and property returns a single value": {
+			ref: esv1beta1.ExternalSecretDataRemoteRef{
+				Key:      "b",
+				Property: "hello",
+			},
+			want: []byte(`world`),
+		},
+		"querying for the key and nested property returns a single value": {
+			ref: esv1beta1.ExternalSecretDataRemoteRef{
+				Key:      "c",
+				Property: "foo.bar",
+			},
+			want: []byte(`baz`),
+		},
+		"querying for existent key and non-existing propery": {
+			ref: esv1beta1.ExternalSecretDataRemoteRef{
+				Key:      "c",
+				Property: "foo.bar.x",
+			},
+			err: esv1beta1.NoSecretErr,
+		},
+	}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			got, err := c.GetSecret(ctx, tc.ref)
+			if tc.err == nil {
+				assert.NoError(t, err)
+				assert.Equal(t, tc.want, got)
+			} else {
+				assert.Nil(t, got)
+				assert.ErrorIs(t, err, tc.err)
+				assert.Equal(t, tc.err, err)
+			}
+		})
+	}
+}

+ 207 - 0
pkg/provider/delinea/provider.go

@@ -0,0 +1,207 @@
+/*
+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
+
+    http://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 delinea
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/DelineaXPM/dsv-sdk-go/v2/vault"
+	corev1 "k8s.io/api/core/v1"
+	kubeClient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	"github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+var (
+	errEmptyTenant                   = errors.New("tenant must not be empty")
+	errEmptyClientID                 = errors.New("clientID must be set")
+	errEmptyClientSecret             = errors.New("clientSecret must be set")
+	errSecretRefAndValueConflict     = errors.New("cannot specify both secret reference and value")
+	errSecretRefAndValueMissing      = errors.New("must specify either secret reference or direct value")
+	errMissingStore                  = errors.New("missing store specification")
+	errInvalidSpec                   = errors.New("invalid specification for delinea provider")
+	errMissingSecretName             = errors.New("must specify a secret name")
+	errMissingSecretKey              = errors.New("must specify a secret key")
+	errClusterStoreRequiresNamespace = errors.New("when using a ClusterSecretStore, namespaces must be explicitly set")
+
+	errNoSuchKeyFmt = "no such key in secret: %q"
+)
+
+type Provider struct{}
+
+var _ esv1beta1.Provider = &Provider{}
+
+// Capabilities return the provider supported capabilities (ReadOnly, WriteOnly, ReadWrite).
+func (p *Provider) Capabilities() esv1beta1.SecretStoreCapabilities {
+	return esv1beta1.SecretStoreReadOnly
+}
+
+func (p *Provider) NewClient(ctx context.Context, store esv1beta1.GenericStore, kube kubeClient.Client, namespace string) (esv1beta1.SecretsClient, error) {
+	cfg, err := getConfig(store)
+	if err != nil {
+		return nil, err
+	}
+
+	if store.GetKind() == esv1beta1.ClusterSecretStoreKind && doesConfigDependOnNamespace(cfg) {
+		// we are not attached to a specific namespace, but some config values are dependent on it
+		return nil, errClusterStoreRequiresNamespace
+	}
+
+	clientID, err := loadConfigSecret(ctx, cfg.ClientID, kube, namespace)
+	if err != nil {
+		return nil, err
+	}
+
+	clientSecret, err := loadConfigSecret(ctx, cfg.ClientSecret, kube, namespace)
+	if err != nil {
+		return nil, err
+	}
+
+	dsvClient, err := vault.New(vault.Configuration{
+		Credentials: vault.ClientCredential{
+			ClientID:     clientID,
+			ClientSecret: clientSecret,
+		},
+		Tenant:      cfg.Tenant,
+		TLD:         cfg.TLD,
+		URLTemplate: cfg.URLTemplate,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &client{
+		api: dsvClient,
+	}, nil
+}
+
+func loadConfigSecret(ctx context.Context, ref *esv1beta1.DelineaProviderSecretRef, kube kubeClient.Client, defaultNamespace string) (string, error) {
+	if ref.SecretRef == nil {
+		return ref.Value, nil
+	}
+
+	if err := validateSecretRef(ref); err != nil {
+		return "", err
+	}
+
+	namespace := defaultNamespace
+	if ref.SecretRef.Namespace != nil {
+		namespace = *ref.SecretRef.Namespace
+	}
+
+	objKey := kubeClient.ObjectKey{Namespace: namespace, Name: ref.SecretRef.Name}
+	secret := corev1.Secret{}
+	err := kube.Get(ctx, objKey, &secret)
+	if err != nil {
+		return "", err
+	}
+
+	value, ok := secret.Data[ref.SecretRef.Key]
+	if !ok {
+		return "", fmt.Errorf(errNoSuchKeyFmt, ref.SecretRef.Key)
+	}
+
+	return string(value), nil
+}
+
+func validateStoreSecretRef(store esv1beta1.GenericStore, ref *esv1beta1.DelineaProviderSecretRef) error {
+	if ref.SecretRef != nil {
+		if err := utils.ValidateReferentSecretSelector(store, *ref.SecretRef); err != nil {
+			return err
+		}
+	}
+
+	return validateSecretRef(ref)
+}
+
+func validateSecretRef(ref *esv1beta1.DelineaProviderSecretRef) error {
+	if ref.SecretRef != nil {
+		if ref.Value != "" {
+			return errSecretRefAndValueConflict
+		}
+		if ref.SecretRef.Name == "" {
+			return errMissingSecretName
+		}
+		if ref.SecretRef.Key == "" {
+			return errMissingSecretKey
+		}
+	} else if ref.Value == "" {
+		return errSecretRefAndValueMissing
+	}
+
+	return nil
+}
+
+func doesConfigDependOnNamespace(cfg *esv1beta1.DelineaProvider) bool {
+	if cfg.ClientID.SecretRef != nil && cfg.ClientID.SecretRef.Namespace == nil {
+		return true
+	}
+
+	if cfg.ClientSecret.SecretRef != nil && cfg.ClientSecret.SecretRef.Namespace == nil {
+		return true
+	}
+
+	return false
+}
+
+func getConfig(store esv1beta1.GenericStore) (*esv1beta1.DelineaProvider, error) {
+	if store == nil {
+		return nil, errMissingStore
+	}
+	storeSpec := store.GetSpec()
+
+	if storeSpec == nil || storeSpec.Provider == nil || storeSpec.Provider.Delinea == nil {
+		return nil, errInvalidSpec
+	}
+	cfg := storeSpec.Provider.Delinea
+
+	if cfg.Tenant == "" {
+		return nil, errEmptyTenant
+	}
+
+	if cfg.ClientID == nil {
+		return nil, errEmptyClientID
+	}
+
+	if cfg.ClientSecret == nil {
+		return nil, errEmptyClientSecret
+	}
+
+	err := validateStoreSecretRef(store, cfg.ClientID)
+	if err != nil {
+		return nil, err
+	}
+
+	err = validateStoreSecretRef(store, cfg.ClientSecret)
+	if err != nil {
+		return nil, err
+	}
+
+	return cfg, nil
+}
+
+func (p *Provider) ValidateStore(store esv1beta1.GenericStore) error {
+	_, err := getConfig(store)
+	return err
+}
+
+func init() {
+	esv1beta1.Register(&Provider{}, &esv1beta1.SecretStoreProvider{
+		Delinea: &esv1beta1.DelineaProvider{},
+	})
+}

+ 369 - 0
pkg/provider/delinea/provider_test.go

@@ -0,0 +1,369 @@
+/*
+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
+
+	http://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 delinea
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/DelineaXPM/dsv-sdk-go/v2/vault"
+	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
+	kubeErrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	kubeClient "sigs.k8s.io/controller-runtime/pkg/client"
+	clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+	v1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+func TestDoesConfigDependOnNamespace(t *testing.T) {
+	tests := map[string]struct {
+		cfg  esv1beta1.DelineaProvider
+		want bool
+	}{
+		"true when client ID references a secret without explicit namespace": {
+			cfg: esv1beta1.DelineaProvider{
+				ClientID: &esv1beta1.DelineaProviderSecretRef{
+					SecretRef: &v1.SecretKeySelector{Name: "foo"},
+				},
+				ClientSecret: &esv1beta1.DelineaProviderSecretRef{SecretRef: nil},
+			},
+			want: true,
+		},
+		"true when client secret references a secret without explicit namespace": {
+			cfg: esv1beta1.DelineaProvider{
+				ClientID: &esv1beta1.DelineaProviderSecretRef{SecretRef: nil},
+				ClientSecret: &esv1beta1.DelineaProviderSecretRef{
+					SecretRef: &v1.SecretKeySelector{Name: "foo"},
+				},
+			},
+			want: true,
+		},
+		"false when neither client ID nor secret reference a secret": {
+			cfg: esv1beta1.DelineaProvider{
+				ClientID:     &esv1beta1.DelineaProviderSecretRef{SecretRef: nil},
+				ClientSecret: &esv1beta1.DelineaProviderSecretRef{SecretRef: nil},
+			},
+			want: false,
+		},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			got := doesConfigDependOnNamespace(&tc.cfg)
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestValidateStore(t *testing.T) {
+	validSecretRefUsingValue := makeSecretRefUsingValue("foo")
+	ambiguousSecretRef := &esv1beta1.DelineaProviderSecretRef{
+		SecretRef: &v1.SecretKeySelector{Name: "foo"}, Value: "foo",
+	}
+	tests := map[string]struct {
+		cfg  esv1beta1.DelineaProvider
+		want error
+	}{
+		"invalid without tenant": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:       "",
+				ClientID:     validSecretRefUsingValue,
+				ClientSecret: validSecretRefUsingValue,
+			},
+			want: errEmptyTenant,
+		},
+		"invalid without clientID": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant: "foo",
+				// ClientID omitted
+				ClientSecret: validSecretRefUsingValue,
+			},
+			want: errEmptyClientID,
+		},
+		"invalid without clientSecret": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:   "foo",
+				ClientID: validSecretRefUsingValue,
+				// ClientSecret omitted
+			},
+			want: errEmptyClientSecret,
+		},
+		"invalid with ambiguous clientID": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:       "foo",
+				ClientID:     ambiguousSecretRef,
+				ClientSecret: validSecretRefUsingValue,
+			},
+			want: errSecretRefAndValueConflict,
+		},
+		"invalid with ambiguous clientSecret": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:       "foo",
+				ClientID:     validSecretRefUsingValue,
+				ClientSecret: ambiguousSecretRef,
+			},
+			want: errSecretRefAndValueConflict,
+		},
+		"invalid with invalid clientID": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:       "foo",
+				ClientID:     makeSecretRefUsingValue(""),
+				ClientSecret: validSecretRefUsingValue,
+			},
+			want: errSecretRefAndValueMissing,
+		},
+		"invalid with invalid clientSecret": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:       "foo",
+				ClientID:     validSecretRefUsingValue,
+				ClientSecret: makeSecretRefUsingValue(""),
+			},
+			want: errSecretRefAndValueMissing,
+		},
+		"valid with tenant/clientID/clientSecret": {
+			cfg: esv1beta1.DelineaProvider{
+				Tenant:       "foo",
+				ClientID:     validSecretRefUsingValue,
+				ClientSecret: validSecretRefUsingValue,
+			},
+			want: nil,
+		},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			s := esv1beta1.SecretStore{
+				Spec: esv1beta1.SecretStoreSpec{
+					Provider: &esv1beta1.SecretStoreProvider{
+						Delinea: &tc.cfg,
+					},
+				},
+			}
+			p := &Provider{}
+			got := p.ValidateStore(&s)
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestValidateStoreBailsOnUnexpectedStore(t *testing.T) {
+	tests := map[string]struct {
+		store esv1beta1.GenericStore
+		want  error
+	}{
+		"missing store": {nil, errMissingStore},
+		"missing spec":  {&esv1beta1.SecretStore{}, errInvalidSpec},
+		"missing provider": {&esv1beta1.SecretStore{
+			Spec: esv1beta1.SecretStoreSpec{Provider: nil},
+		}, errInvalidSpec},
+		"missing delinea": {&esv1beta1.SecretStore{
+			Spec: esv1beta1.SecretStoreSpec{Provider: &esv1beta1.SecretStoreProvider{
+				Delinea: nil,
+			}},
+		}, errInvalidSpec},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			p := &Provider{}
+			got := p.ValidateStore(tc.store)
+			assert.Equal(t, tc.want, got)
+		})
+	}
+}
+
+func TestNewClient(t *testing.T) {
+	tenant := "foo"
+	clientIDKey := "username"
+	clientIDValue := "client id"
+	clientSecretKey := "password"
+	clientSecretValue := "client secret"
+
+	clientSecret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default"},
+		Data: map[string][]byte{
+			clientIDKey:     []byte(clientIDValue),
+			clientSecretKey: []byte(clientSecretValue),
+		},
+	}
+
+	validProvider := &esv1beta1.DelineaProvider{
+		Tenant:       tenant,
+		ClientID:     makeSecretRefUsingRef(clientSecret.Name, clientIDKey),
+		ClientSecret: makeSecretRefUsingRef(clientSecret.Name, clientSecretKey),
+	}
+
+	tests := map[string]struct {
+		store    esv1beta1.GenericStore     // leave nil for namespaced store
+		provider *esv1beta1.DelineaProvider // discarded when store is set
+		kube     kubeClient.Client
+		errCheck func(t *testing.T, err error)
+	}{
+		"missing provider config": {
+			provider: nil,
+			errCheck: func(t *testing.T, err error) {
+				assert.ErrorIs(t, err, errInvalidSpec)
+			},
+		},
+		"namespace-dependent cluster secret store": {
+			store: &esv1beta1.ClusterSecretStore{
+				TypeMeta: metav1.TypeMeta{Kind: esv1beta1.ClusterSecretStoreKind},
+				Spec: esv1beta1.SecretStoreSpec{
+					Provider: &esv1beta1.SecretStoreProvider{
+						Delinea: validProvider,
+					},
+				},
+			},
+			errCheck: func(t *testing.T, err error) {
+				assert.ErrorIs(t, err, errClusterStoreRequiresNamespace)
+			},
+		},
+		"dangling client ID ref": {
+			provider: &esv1beta1.DelineaProvider{
+				Tenant:       tenant,
+				ClientID:     makeSecretRefUsingRef("typo", clientIDKey),
+				ClientSecret: makeSecretRefUsingRef(clientSecret.Name, clientSecretKey),
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+			errCheck: func(t *testing.T, err error) {
+				assert.True(t, kubeErrors.IsNotFound(err))
+			},
+		},
+		"dangling client secret ref": {
+			provider: &esv1beta1.DelineaProvider{
+				Tenant:       tenant,
+				ClientID:     makeSecretRefUsingRef(clientSecret.Name, clientIDKey),
+				ClientSecret: makeSecretRefUsingRef("typo", clientSecretKey),
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+			errCheck: func(t *testing.T, err error) {
+				assert.True(t, kubeErrors.IsNotFound(err))
+			},
+		},
+		"secret ref without name": {
+			provider: &esv1beta1.DelineaProvider{
+				Tenant:       tenant,
+				ClientID:     makeSecretRefUsingRef("", clientIDKey),
+				ClientSecret: makeSecretRefUsingRef(clientSecret.Name, clientSecretKey),
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+			errCheck: func(t *testing.T, err error) {
+				assert.ErrorIs(t, err, errMissingSecretName)
+			},
+		},
+		"secret ref without key": {
+			provider: &esv1beta1.DelineaProvider{
+				Tenant:       tenant,
+				ClientID:     makeSecretRefUsingRef(clientSecret.Name, ""),
+				ClientSecret: makeSecretRefUsingRef(clientSecret.Name, clientSecretKey),
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+			errCheck: func(t *testing.T, err error) {
+				assert.ErrorIs(t, err, errMissingSecretKey)
+			},
+		},
+		"secret ref with non-existent keys": {
+			provider: &esv1beta1.DelineaProvider{
+				Tenant:       tenant,
+				ClientID:     makeSecretRefUsingRef(clientSecret.Name, "typo"),
+				ClientSecret: makeSecretRefUsingRef(clientSecret.Name, clientSecretKey),
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+			errCheck: func(t *testing.T, err error) {
+				assert.EqualError(t, err, fmt.Sprintf(errNoSuchKeyFmt, "typo"))
+			},
+		},
+		"valid secret refs": {
+			provider: validProvider,
+			kube:     clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+		},
+		"secret values": {
+			provider: &esv1beta1.DelineaProvider{
+				Tenant:       tenant,
+				ClientID:     makeSecretRefUsingValue(clientIDValue),
+				ClientSecret: makeSecretRefUsingValue(clientSecretValue),
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+		},
+		"cluster secret store": {
+			store: &esv1beta1.ClusterSecretStore{
+				TypeMeta: metav1.TypeMeta{Kind: esv1beta1.ClusterSecretStoreKind},
+				Spec: esv1beta1.SecretStoreSpec{
+					Provider: &esv1beta1.SecretStoreProvider{
+						Delinea: &esv1beta1.DelineaProvider{
+							Tenant:       tenant,
+							ClientID:     makeSecretRefUsingNamespacedRef(clientSecret.Namespace, clientSecret.Name, clientIDKey),
+							ClientSecret: makeSecretRefUsingNamespacedRef(clientSecret.Namespace, clientSecret.Name, clientSecretKey),
+						},
+					},
+				},
+			},
+			kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(),
+		},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			p := &Provider{}
+			store := tc.store
+			if store == nil {
+				store = &esv1beta1.SecretStore{
+					TypeMeta: metav1.TypeMeta{Kind: esv1beta1.SecretStoreKind},
+					Spec: esv1beta1.SecretStoreSpec{
+						Provider: &esv1beta1.SecretStoreProvider{
+							Delinea: tc.provider,
+						},
+					},
+				}
+			}
+			sc, err := p.NewClient(context.Background(), store, tc.kube, clientSecret.Namespace)
+			if tc.errCheck == nil {
+				assert.NoError(t, err)
+				delineaClient, ok := sc.(*client)
+				assert.True(t, ok)
+				dsvClient, ok := delineaClient.api.(*vault.Vault)
+				assert.True(t, ok)
+				assert.Equal(t, vault.Configuration{
+					Credentials: vault.ClientCredential{
+						ClientID:     clientIDValue,
+						ClientSecret: clientSecretValue,
+					},
+					Tenant:      tenant,
+					TLD:         "com",                                     // Default from Delinea
+					URLTemplate: "https://%s.secretsvaultcloud.%s/v1/%s%s", // Default from Delinea
+				}, dsvClient.Configuration)
+			} else {
+				assert.Nil(t, sc)
+				tc.errCheck(t, err)
+			}
+		})
+	}
+}
+
+func makeSecretRefUsingRef(name, key string) *esv1beta1.DelineaProviderSecretRef {
+	return &esv1beta1.DelineaProviderSecretRef{
+		SecretRef: &v1.SecretKeySelector{Name: name, Key: key},
+	}
+}
+
+func makeSecretRefUsingNamespacedRef(namespace, name, key string) *esv1beta1.DelineaProviderSecretRef {
+	return &esv1beta1.DelineaProviderSecretRef{
+		SecretRef: &v1.SecretKeySelector{Namespace: utils.Ptr(namespace), Name: name, Key: key},
+	}
+}
+
+func makeSecretRefUsingValue(val string) *esv1beta1.DelineaProviderSecretRef {
+	return &esv1beta1.DelineaProviderSecretRef{Value: val}
+}

+ 25 - 0
pkg/provider/delinea/secret_api.go

@@ -0,0 +1,25 @@
+/*
+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
+
+	http://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 delinea
+
+import (
+	"github.com/DelineaXPM/dsv-sdk-go/v2/vault"
+)
+
+// secretAPI represents the subset of the Delinea DevOps Secrets Vault API
+// which is supported by dsv-sdk-go/v2.
+// See https://dsv.secretsvaultcloud.com/api for full API documentation.
+type secretAPI interface {
+	Secret(path string) (*vault.Secret, error)
+}

+ 1 - 0
pkg/provider/register/register.go

@@ -23,6 +23,7 @@ import (
 	_ "github.com/external-secrets/external-secrets/pkg/provider/aws"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/azure/keyvault"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/conjur"
+	_ "github.com/external-secrets/external-secrets/pkg/provider/delinea"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/doppler"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/fake"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/gcp/secretmanager"