From bdf437c2e1a1a441955b1036b84eed5f63bda5d9 Mon Sep 17 00:00:00 2001 From: Michael Sauter Date: Thu, 6 Jul 2023 18:01:43 +0200 Subject: [PATCH] Add support for Delinea DevOps Secrets Vault (#2415) * Add support for Delinea DevOps Secrets Vault Closes #1709. Signed-off-by: Michael Sauter * fix: remove merge conflict Signed-off-by: Moritz Johner * Improve documentation Signed-off-by: Michael Sauter --------- Signed-off-by: Michael Sauter Signed-off-by: Moritz Johner Co-authored-by: Moritz Johner Co-authored-by: Moritz Johner --- .../v1beta1/secretsstore_delinea_types.go | 51 +++ .../v1beta1/secretstore_types.go | 5 + .../v1beta1/zz_generated.deepcopy.go | 50 +++ ...ternal-secrets.io_clustersecretstores.yaml | 72 ++++ .../external-secrets.io_secretstores.yaml | 72 ++++ deploy/crds/bundle.yaml | 114 ++++++ docs/api/spec.md | 141 +++++++ docs/introduction/stability-support.md | 2 + docs/provider/delinea.md | 56 +++ e2e/go.mod | 1 + e2e/go.sum | 2 + e2e/run.sh | 5 + e2e/suites/provider/cases/common/common.go | 2 +- e2e/suites/provider/cases/delinea/config.go | 47 +++ e2e/suites/provider/cases/delinea/delinea.go | 115 ++++++ e2e/suites/provider/cases/delinea/provider.go | 47 +++ e2e/suites/provider/cases/import.go | 1 + go.mod | 1 + go.sum | 2 + hack/api-docs/mkdocs.yml | 1 + pkg/provider/delinea/client.go | 148 +++++++ pkg/provider/delinea/client_test.go | 117 ++++++ pkg/provider/delinea/provider.go | 207 ++++++++++ pkg/provider/delinea/provider_test.go | 369 ++++++++++++++++++ pkg/provider/delinea/secret_api.go | 25 ++ pkg/provider/register/register.go | 1 + 26 files changed, 1653 insertions(+), 1 deletion(-) create mode 100644 apis/externalsecrets/v1beta1/secretsstore_delinea_types.go create mode 100644 docs/provider/delinea.md create mode 100644 e2e/suites/provider/cases/delinea/config.go create mode 100644 e2e/suites/provider/cases/delinea/delinea.go create mode 100644 e2e/suites/provider/cases/delinea/provider.go create mode 100644 pkg/provider/delinea/client.go create mode 100644 pkg/provider/delinea/client_test.go create mode 100644 pkg/provider/delinea/provider.go create mode 100644 pkg/provider/delinea/provider_test.go create mode 100644 pkg/provider/delinea/secret_api.go diff --git a/apis/externalsecrets/v1beta1/secretsstore_delinea_types.go b/apis/externalsecrets/v1beta1/secretsstore_delinea_types.go new file mode 100644 index 000000000..ab3482239 --- /dev/null +++ b/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"` +} diff --git a/apis/externalsecrets/v1beta1/secretstore_types.go b/apis/externalsecrets/v1beta1/secretstore_types.go index f7d0f2879..efa23ef0b 100644 --- a/apis/externalsecrets/v1beta1/secretstore_types.go +++ b/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 diff --git a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go index 37ec00270..b9c7b21f4 100644 --- a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go +++ b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go @@ -701,6 +701,51 @@ func (in *ConjurProvider) DeepCopy() *ConjurProvider { return out } +// 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 @@ -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. diff --git a/config/crds/bases/external-secrets.io_clustersecretstores.yaml b/config/crds/bases/external-secrets.io_clustersecretstores.yaml index 40eb66103..52a285e08 100644 --- a/config/crds/bases/external-secrets.io_clustersecretstores.yaml +++ b/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 diff --git a/config/crds/bases/external-secrets.io_secretstores.yaml b/config/crds/bases/external-secrets.io_secretstores.yaml index a8d9b5309..612660c26 100644 --- a/config/crds/bases/external-secrets.io_secretstores.yaml +++ b/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 diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml index a4802c081..31eea9bec 100644 --- a/deploy/crds/bundle.yaml +++ b/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: diff --git a/docs/api/spec.md b/docs/api/spec.md index 371dddf49..a1758009a 100644 --- a/docs/api/spec.md +++ b/docs/api/spec.md @@ -1775,6 +1775,132 @@ ConjurAuth +

DelineaProvider +

+

+(Appears on: +SecretStoreProvider) +

+

+

See https://github.com/DelineaXPM/dsv-sdk-go/blob/main/vault/vault.go.

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+clientId
+ + +DelineaProviderSecretRef + + +
+

ClientID is the non-secret part of the credential.

+
+clientSecret
+ + +DelineaProviderSecretRef + + +
+

ClientSecret is the secret part of the credential.

+
+tenant
+ +string + +
+

Tenant is the chosen hostname / site name.

+
+urlTemplate
+ +string + +
+(Optional) +

URLTemplate +If unset, defaults to “https://%s.secretsvaultcloud.%s/v1/%s%s”.

+
+tld
+ +string + +
+(Optional) +

TLD is based on the server location that was chosen during provisioning. +If unset, defaults to “com”.

+
+

DelineaProviderSecretRef +

+

+(Appears on: +DelineaProvider) +

+

+

+ + + + + + + + + + + + + + + + + +
FieldDescription
+value
+ +string + +
+(Optional) +

Value can be specified directly to set a value without using a secret.

+
+secretRef
+ + +External Secrets meta/v1.SecretKeySelector + + +
+(Optional) +

SecretRef references a key in a secret that will be used as value.

+

DopplerAuth

@@ -4865,6 +4991,21 @@ ConjurProvider

Conjur configures this store to sync secrets using conjur provider

+ + +delinea
+ + +DelineaProvider + + + + +(Optional) +

Delinea DevOps Secrets Vault +https://docs.delinea.com/online-help/products/devops-secrets-vault/current

+ +

SecretStoreRef diff --git a/docs/introduction/stability-support.md b/docs/introduction/stability-support.md index 81807bfa1..78ebdd435 100644 --- a/docs/introduction/stability-support.md +++ b/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 diff --git a/docs/provider/delinea.md b/docs/provider/delinea.md new file mode 100644 index 000000000..3b92f152a --- /dev/null +++ b/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: + tld: + clientId: + value: + clientSecret: + secretRef: + name: + key: +``` + +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: + remoteRef: + key: +``` diff --git a/e2e/go.mod b/e2e/go.mod index fa2a35360..99f6bdef1 100644 --- a/e2e/go.mod +++ b/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 diff --git a/e2e/go.sum b/e2e/go.sum index 995e0533f..2cda3bc37 100644 --- a/e2e/go.sum +++ b/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= diff --git a/e2e/run.sh b/e2e/run.sh index c1a029620..9977afe46 100755 --- a/e2e/run.sh +++ b/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"}}' \ diff --git a/e2e/suites/provider/cases/common/common.go b/e2e/suites/provider/cases/common/common.go index cf2b9da5c..67d4c1fb3 100644 --- a/e2e/suites/provider/cases/common/common.go +++ b/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}, diff --git a/e2e/suites/provider/cases/delinea/config.go b/e2e/suites/provider/cases/delinea/config.go new file mode 100644 index 000000000..96da8e9b7 --- /dev/null +++ b/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 +} diff --git a/e2e/suites/provider/cases/delinea/delinea.go b/e2e/suites/provider/cases/delinea/delinea.go new file mode 100644 index 000000000..93d22c4a1 --- /dev/null +++ b/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()) +} diff --git a/e2e/suites/provider/cases/delinea/provider.go b/e2e/suites/provider/cases/delinea/provider.go new file mode 100644 index 000000000..845143571 --- /dev/null +++ b/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()) +} diff --git a/e2e/suites/provider/cases/import.go b/e2e/suites/provider/cases/import.go index c3b2fd2ec..8a88f7de3 100644 --- a/e2e/suites/provider/cases/import.go +++ b/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" diff --git a/go.mod b/go.mod index 535ca751c..3475f2a56 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 730e7dd2b..586350fd7 100644 --- a/go.sum +++ b/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= diff --git a/hack/api-docs/mkdocs.yml b/hack/api-docs/mkdocs.yml index 00eef1012..c6f22a739 100644 --- a/hack/api-docs/mkdocs.yml +++ b/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 diff --git a/pkg/provider/delinea/client.go b/pkg/provider/delinea/client.go new file mode 100644 index 000000000..72172d461 --- /dev/null +++ b/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)) + } +} diff --git a/pkg/provider/delinea/client_test.go b/pkg/provider/delinea/client_test.go new file mode 100644 index 000000000..281e0e7d4 --- /dev/null +++ b/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) + } + }) + } +} diff --git a/pkg/provider/delinea/provider.go b/pkg/provider/delinea/provider.go new file mode 100644 index 000000000..68e809cb3 --- /dev/null +++ b/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{}, + }) +} diff --git a/pkg/provider/delinea/provider_test.go b/pkg/provider/delinea/provider_test.go new file mode 100644 index 000000000..cc779ba5d --- /dev/null +++ b/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} +} diff --git a/pkg/provider/delinea/secret_api.go b/pkg/provider/delinea/secret_api.go new file mode 100644 index 000000000..52a0eba0d --- /dev/null +++ b/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) +} diff --git a/pkg/provider/register/register.go b/pkg/provider/register/register.go index 1f5499405..68004c4a0 100644 --- a/pkg/provider/register/register.go +++ b/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"