diff --git a/apis/externalsecrets/v1beta1/secretsstore_secretserver_types.go b/apis/externalsecrets/v1beta1/secretsstore_secretserver_types.go new file mode 100644 index 000000000..41d75dade --- /dev/null +++ b/apis/externalsecrets/v1beta1/secretsstore_secretserver_types.go @@ -0,0 +1,45 @@ +/* +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 SecretServerProviderRef 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/tss-sdk-go/blob/main/server/server.go. +type SecretServerProvider struct { + + // Username is the secret server account username. + // +required + Username *SecretServerProviderRef `json:"username"` + + // Password is the secret server account password. + // +required + Password *SecretServerProviderRef `json:"password"` + + // ServerURL + // URL to your secret server installation + // +required + ServerURL string `json:"serverURL"` +} diff --git a/apis/externalsecrets/v1beta1/secretstore_types.go b/apis/externalsecrets/v1beta1/secretstore_types.go index 112e58860..d482c078c 100644 --- a/apis/externalsecrets/v1beta1/secretstore_types.go +++ b/apis/externalsecrets/v1beta1/secretstore_types.go @@ -155,6 +155,11 @@ type SecretStoreProvider struct { // +optional Delinea *DelineaProvider `json:"delinea,omitempty"` + // SecretServer configures this store to sync secrets using SecretServer provider + // https://docs.delinea.com/online-help/secret-server/start.htm + // +optional + SecretServer *SecretServerProvider `json:"secretserver,omitempty"` + // Chef configures this store to sync secrets with chef server // +optional Chef *ChefProvider `json:"chef,omitempty"` diff --git a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go index 2fc04558c..05a706316 100644 --- a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go +++ b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go @@ -2266,6 +2266,51 @@ func (in *ScalewayProviderSecretRef) DeepCopy() *ScalewayProviderSecretRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretServerProvider) DeepCopyInto(out *SecretServerProvider) { + *out = *in + if in.Username != nil { + in, out := &in.Username, &out.Username + *out = new(SecretServerProviderRef) + (*in).DeepCopyInto(*out) + } + if in.Password != nil { + in, out := &in.Password, &out.Password + *out = new(SecretServerProviderRef) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretServerProvider. +func (in *SecretServerProvider) DeepCopy() *SecretServerProvider { + if in == nil { + return nil + } + out := new(SecretServerProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretServerProviderRef) DeepCopyInto(out *SecretServerProviderRef) { + *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 SecretServerProviderRef. +func (in *SecretServerProviderRef) DeepCopy() *SecretServerProviderRef { + if in == nil { + return nil + } + out := new(SecretServerProviderRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SecretStore) DeepCopyInto(out *SecretStore) { *out = *in @@ -2443,6 +2488,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) { *out = new(DelineaProvider) (*in).DeepCopyInto(*out) } + if in.SecretServer != nil { + in, out := &in.SecretServer, &out.SecretServer + *out = new(SecretServerProvider) + (*in).DeepCopyInto(*out) + } if in.Chef != nil { in, out := &in.Chef, &out.Chef *out = new(ChefProvider) diff --git a/config/crds/bases/external-secrets.io_clustersecretstores.yaml b/config/crds/bases/external-secrets.io_clustersecretstores.yaml index cba6e4b8d..310c1aa79 100644 --- a/config/crds/bases/external-secrets.io_clustersecretstores.yaml +++ b/config/crds/bases/external-secrets.io_clustersecretstores.yaml @@ -3732,6 +3732,75 @@ spec: - region - secretKey type: object + secretserver: + description: |- + SecretServer configures this store to sync secrets using SecretServer provider + https://docs.delinea.com/online-help/secret-server/start.htm + properties: + password: + description: Password is the secret server account password. + 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 + serverURL: + description: |- + ServerURL + URL to your secret server installation + type: string + username: + description: Username is the secret server account username. + 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 + required: + - password + - serverURL + - username + type: object senhasegura: description: Senhasegura configures this store to sync secrets using senhasegura provider diff --git a/config/crds/bases/external-secrets.io_secretstores.yaml b/config/crds/bases/external-secrets.io_secretstores.yaml index e9ff3e815..b6a25e66e 100644 --- a/config/crds/bases/external-secrets.io_secretstores.yaml +++ b/config/crds/bases/external-secrets.io_secretstores.yaml @@ -3732,6 +3732,75 @@ spec: - region - secretKey type: object + secretserver: + description: |- + SecretServer configures this store to sync secrets using SecretServer provider + https://docs.delinea.com/online-help/secret-server/start.htm + properties: + password: + description: Password is the secret server account password. + 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 + serverURL: + description: |- + ServerURL + URL to your secret server installation + type: string + username: + description: Username is the secret server account username. + 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 + required: + - password + - serverURL + - username + type: object senhasegura: description: Senhasegura configures this store to sync secrets using senhasegura provider diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml index c4c9a04b5..d15190a08 100644 --- a/deploy/crds/bundle.yaml +++ b/deploy/crds/bundle.yaml @@ -4121,6 +4121,69 @@ spec: - region - secretKey type: object + secretserver: + description: |- + SecretServer configures this store to sync secrets using SecretServer provider + https://docs.delinea.com/online-help/secret-server/start.htm + properties: + password: + description: Password is the secret server account password. + 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 + serverURL: + description: |- + ServerURL + URL to your secret server installation + type: string + username: + description: Username is the secret server account username. + 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 + required: + - password + - serverURL + - username + type: object senhasegura: description: Senhasegura configures this store to sync secrets using senhasegura provider properties: @@ -9684,6 +9747,69 @@ spec: - region - secretKey type: object + secretserver: + description: |- + SecretServer configures this store to sync secrets using SecretServer provider + https://docs.delinea.com/online-help/secret-server/start.htm + properties: + password: + description: Password is the secret server account password. + 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 + serverURL: + description: |- + ServerURL + URL to your secret server installation + type: string + username: + description: Username is the secret server account username. + 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 + required: + - password + - serverURL + - username + type: object senhasegura: description: Senhasegura configures this store to sync secrets using senhasegura provider properties: diff --git a/docs/api/spec.md b/docs/api/spec.md index 2613780dc..554706bba 100644 --- a/docs/api/spec.md +++ b/docs/api/spec.md @@ -5924,6 +5924,107 @@ External Secrets meta/v1.SecretKeySelector +

SecretServerProvider +

+

+(Appears on: +SecretStoreProvider) +

+

+

See https://github.com/DelineaXPM/tss-sdk-go/blob/main/server/server.go.

+

+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+username
+ + +SecretServerProviderRef + + +
+

Username is the secret server account username.

+
+password
+ + +SecretServerProviderRef + + +
+

Password is the secret server account password.

+
+serverURL
+ +string + +
+

ServerURL +URL to your secret server installation

+
+

SecretServerProviderRef +

+

+(Appears on: +SecretServerProvider) +

+

+

+ + + + + + + + + + + + + + + + + +
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.

+

SecretStore

@@ -6432,6 +6533,21 @@ DelineaProvider +secretserver
+ + +SecretServerProvider + + + + +(Optional) +

SecretServer configures this store to sync secrets using SecretServer provider +https://docs.delinea.com/online-help/secret-server/start.htm

+ + + + chef
diff --git a/docs/introduction/stability-support.md b/docs/introduction/stability-support.md index 189dbfa00..f617a9255 100644 --- a/docs/introduction/stability-support.md +++ b/docs/introduction/stability-support.md @@ -53,6 +53,7 @@ The following table describes the stability level of each provider and who's res | [Scaleway](https://external-secrets.io/latest/provider/scaleway) | alpha | [@azert9](https://github.com/azert9/) | | [Conjur](https://external-secrets.io/latest/provider/conjur) | stable | [@davidh-cyberark](https://github.com/davidh-cyberark/) [@szh](https://github.com/szh) | | [Delinea](https://external-secrets.io/latest/provider/delinea) | alpha | [@michaelsauter](https://github.com/michaelsauter/) | +| [SecretServer](https://external-secrets.io/latest/provider/secretserver) | alpha | [@billhamilton](https://github.com/pacificcode/) | | [Pulumi ESC](https://external-secrets.io/latest/provider/pulumi) | alpha | [@dirien](https://github.com/dirien) | | [Passbolt](https://external-secrets.io/latest/provider/passbolt) | alpha | | | [Infisical](https://external-secrets.io/latest/provider/infisical) | alpha | [@akhilmhdh](https://github.com/akhilmhdh) | @@ -85,6 +86,7 @@ The following table show the support for features across different providers. | Scaleway | x | x | | | x | x | x | | Conjur | x | x | | | x | | | | Delinea | x | | | | x | | | +| SecretServer | x | | | | x | | | | Pulumi ESC | x | | | | x | | | | Passbolt | x | | | | x | | | | Infisical | x | | | x | x | | | diff --git a/docs/provider/secretserver.md b/docs/provider/secretserver.md new file mode 100644 index 000000000..e7eeafcf9 --- /dev/null +++ b/docs/provider/secretserver.md @@ -0,0 +1,133 @@ +# Delinea Secret Server + +External Secrets Operator integration with [Delinea Secret Server](https://docs.delinea.com/online-help/secret-server/start.htm). + +### Creating a SecretStore + +You need a username, password and a fully qualified Secret Server tenant URL to authenticate +i.e. `https://yourTenantName.secretservercloud.com`. + +Both username and password can be specified either directly in your `SecretStore` yaml config, or by referencing a kubernetes secret. + +To acquire a username and password, refer to the Secret Server [user management](https://docs.delinea.com/online-help/secret-server/users/creating-users/index.htm) documentation. + +Both `username` and `password` can either be specified directly via the `value` field (example below) +>spec.provider.secretserver.username.value: "yourusername"
+spec.provider.secretserver.password.value: "yourpassword"
+ +Or you can reference a kubernetes secret (password example below). + +```yaml +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: secret-server-store +spec: + provider: + secretserver: + serverURL: "https://yourtenantname.secretservercloud.com" + username: + value: "yourusername" + password: + secretRef: + name: + key: +``` + +### Referencing Secrets + +Secrets may be referenced by secret ID or secret name. +>Please note if using the secret name +the name field must not contain spaces or control characters.
+If multiple secrets are found, *`only the first found secret will be returned`*. + +Please note: `Retrieving a specific version of a secret is not yet supported.` + +Note that because all Secret Server secrets are JSON objects, you must specify the `remoteRef.property` +in your ExternalSecret configuration.
+You can access nested values or arrays using [gjson syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md). + +```yaml +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: secret-server-external-secret +spec: + refreshInterval: 15s + secretStoreRef: + kind: SecretStore + name: secret-server-store + data: + - secretKey: SecretServerValue # + remoteRef: + key: "52622" # + property: "array.0.value" # * an empty property will return the entire secret +``` + +### Preparing your secret +You can either retrieve your entire secret or you can use a JSON formatted string +stored in your secret located at Items[0].ItemValue to retrieve a specific value.
+See example JSON secret below. + +### Examples +Using the json formatted secret below: + +- Lookup a single top level property using secret ID. + +>spec.data.remoteRef.key = 52622 (id of the secret)
+spec.data.remoteRef.property = "user" (Items.0.ItemValue user attribute)
+returns: marktwain@hannibal.com + +- Lookup a nested property using secret name. + +>spec.data.remoteRef.key = "external-secret-testing" (name of the secret)
+spec.data.remoteRef.property = "books.1" (Items.0.ItemValue books.1 attribute)
+returns: huckleberryFinn + +- Lookup by secret ID (*secret name will work as well*) and return the entire secret. + +>spec.data.remoteRef.key = "52622" (id of the secret)
+spec.data.remoteRef.property = ""
+returns: The entire secret in JSON format as displayed below + + +```JSON +{ + "Name": "external-secret-testing", + "FolderID": 73, + "ID": 52622, + "SiteID": 1, + "SecretTemplateID": 6098, + "SecretPolicyID": -1, + "PasswordTypeWebScriptID": -1, + "LauncherConnectAsSecretID": -1, + "CheckOutIntervalMinutes": -1, + "Active": true, + "CheckedOut": false, + "CheckOutEnabled": false, + "AutoChangeEnabled": false, + "CheckOutChangePasswordEnabled": false, + "DelayIndexing": false, + "EnableInheritPermissions": true, + "EnableInheritSecretPolicy": true, + "ProxyEnabled": false, + "RequiresComment": false, + "SessionRecordingEnabled": false, + "WebLauncherRequiresIncognitoMode": false, + "Items": [ + { + "ItemID": 280265, + "FieldID": 439, + "FileAttachmentID": 0, + "FieldName": "Data", + "Slug": "data", + "FieldDescription": "json text field", + "Filename": "", + "ItemValue": "{ \"user\": \"marktwain@hannibal.com\", \"occupation\": \"author\",\"books\":[ \"tomSawyer\",\"huckleberryFinn\",\"Pudd'nhead Wilson\"] }", + "IsFile": false, + "IsNotes": false, + "IsPassword": false + } + ] +} +``` diff --git a/e2e/go.mod b/e2e/go.mod index 5ba119845..9cc0cc00b 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -44,6 +44,7 @@ require ( github.com/Azure/go-autorest/autorest v0.11.29 github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.2 + github.com/DelineaXPM/tss-sdk-go/v2 v2.0.1 github.com/akeylesslabs/akeyless-go-cloud-id v0.3.5 github.com/akeylesslabs/akeyless-go/v3 v3.6.3 github.com/aliyun/alibaba-cloud-sdk-go v1.62.271 diff --git a/e2e/go.sum b/e2e/go.sum index ef1a96309..39c136281 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -97,6 +97,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 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.2 h1:cmX2QC9s5kPqmghWLLZP8YRFO1ZD/C59BpNH2ujP99w= github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.2/go.mod h1:tNlpIXJlIwQlRbobXDPme4qv/Rc8+a1GbuUhE3m4JhQ= +github.com/DelineaXPM/tss-sdk-go/v2 v2.0.1 h1:/rzzzaBuj/FYTcbt8sYZ9IzlnENqcgh5zKqBhHiBBm4= +github.com/DelineaXPM/tss-sdk-go/v2 v2.0.1/go.mod h1:xz6FXP2Do88Vc5Hx7OamZgZC1W45yfmLy4+iDKxlGXo= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= diff --git a/e2e/run.sh b/e2e/run.sh index f71c134a1..52cebd976 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -84,6 +84,9 @@ kubectl run --rm \ --env="DELINEA_TENANT=${DELINEA_TENANT:-}" \ --env="DELINEA_CLIENT_ID=${DELINEA_CLIENT_ID:-}" \ --env="DELINEA_CLIENT_SECRET=${DELINEA_CLIENT_SECRET:-}" \ + --env="SECRETSERVER_USERNAME=${SECRETSERVER_USERNAME:-}" \ + --env="SECRETSERVER_PASSWORD=${SECRETSERVER_PASSWORD:-}" \ + --env="SECRETSERVER_URL=${SECRETSERVER_URL:-}" \ --env="VERSION=${VERSION}" \ --env="TEST_SUITES=${TEST_SUITES}" \ --overrides='{ "apiVersion": "v1", "spec":{"serviceAccountName": "external-secrets-e2e"}}' \ diff --git a/e2e/suites/provider/cases/import.go b/e2e/suites/provider/cases/import.go index e8561c561..5a20e529f 100644 --- a/e2e/suites/provider/cases/import.go +++ b/e2e/suites/provider/cases/import.go @@ -27,4 +27,5 @@ import ( _ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/template" _ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/vault" _ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/conjur" + _ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/secretserver" ) diff --git a/e2e/suites/provider/cases/secretserver/config.go b/e2e/suites/provider/cases/secretserver/config.go new file mode 100644 index 000000000..bb7bc8651 --- /dev/null +++ b/e2e/suites/provider/cases/secretserver/config.go @@ -0,0 +1,41 @@ +package secretserver + +import ( + "fmt" + "os" +) + +type config struct { + username string + password string + serverURL string +} + +func loadConfigFromEnv() (*config, error) { + var cfg config + var err error + + // Required settings + cfg.username, err = getEnv("SECRETSERVER_USERNAME") + if err != nil { + return nil, err + } + cfg.password, err = getEnv("SECRETSERVER_PASSWORD") + if err != nil { + return nil, err + } + cfg.serverURL, err = getEnv("SECRETSERVER_URL") + 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/secretserver/provider.go b/e2e/suites/provider/cases/secretserver/provider.go new file mode 100644 index 000000000..9b1b7cf33 --- /dev/null +++ b/e2e/suites/provider/cases/secretserver/provider.go @@ -0,0 +1,58 @@ +package secretserver + +import ( + "encoding/json" + + "github.com/DelineaXPM/tss-sdk-go/v2/server" + "github.com/external-secrets/external-secrets-e2e/framework" + "github.com/onsi/gomega" +) + + +type secretStoreProvider struct { + api *server.Server + cfg *config + framework *framework.Framework + secretID map[string]int +} + +func (p *secretStoreProvider) init(cfg *config, f *framework.Framework) { + p.cfg = cfg + p.secretID = make(map[string]int) + p.framework = f + secretserverClient, err := server.New(server.Configuration{ + Credentials: server.UserCredential{ + Username: cfg.username, + Password: cfg.password, + }, + ServerURL: cfg.serverURL, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + p.api = secretserverClient +} + +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()) + + fields := make([]server.SecretField, 1) + fields[0].FieldID = 329 // Data + fields[0].ItemValue = val.Value + + s, err := p.api.CreateSecret(server.Secret{ + SecretTemplateID: 6051, // custom template + SiteID: 1, + FolderID: 10, + Name: key, + Fields: fields, + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + p.secretID[key] = s.ID +} + +func (p *secretStoreProvider) DeleteSecret(key string) { + err := p.api.DeleteSecret(p.secretID[key]) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) +} diff --git a/e2e/suites/provider/cases/secretserver/secretserver.go b/e2e/suites/provider/cases/secretserver/secretserver.go new file mode 100644 index 000000000..f0ba2bfee --- /dev/null +++ b/e2e/suites/provider/cases/secretserver/secretserver.go @@ -0,0 +1,92 @@ +package secretserver + +import ( + "context" + _"fmt" + "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("[secretserver]", ginkgo.Label("secretserver"), func() { + + f := framework.New("eso-secretserver") + + // Initialization is deferred so that assertions work. + provider := &secretStoreProvider{} + + ginkgo.BeforeEach(func() { + + cfg, err := loadConfigFromEnv() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + provider.init(cfg, f) + createResources(context.Background(), f, cfg) + }) + + ginkgo.DescribeTable("sync secrets", framework.TableFuncWithExternalSecret(f, provider), + ginkgo.Entry(common.JSONDataWithTemplate(f)), + ginkgo.Entry(common.JSONDataWithProperty(f)), + ginkgo.Entry(common.JSONDataWithoutTargetName(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)), // <-- + ) +}) + +func createResources(ctx context.Context, f *framework.Framework, cfg *config) { + + secretName := "secretserver-credential" + secretKey := "password" + // 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.password, + }, + } + + 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{ + SecretServer: &esv1beta1.SecretServerProvider{ + ServerURL: cfg.serverURL, + Username: &esv1beta1.SecretServerProviderRef{ + Value: cfg.username, + }, + Password: &esv1beta1.SecretServerProviderRef{ + SecretRef: &esmeta.SecretKeySelector{ + Name: secretName, + Key: secretKey, + }, + }, + }, + }, + }, + } + + err = f.CRClient.Create(ctx, &secretStoreSpec) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) +} diff --git a/go.mod b/go.mod index 3e42fa63b..9fc3771e0 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.2 + github.com/DelineaXPM/tss-sdk-go/v2 v2.0.1 github.com/Onboardbase/go-cryptojs-aes-decrypt v0.0.0-20230430095000-27c0d3a9016d github.com/akeylesslabs/akeyless-go/v3 v3.6.3 github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.8 diff --git a/go.sum b/go.sum index 6197090c1..964d60d3c 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 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.2 h1:cmX2QC9s5kPqmghWLLZP8YRFO1ZD/C59BpNH2ujP99w= github.com/DelineaXPM/dsv-sdk-go/v2 v2.1.2/go.mod h1:tNlpIXJlIwQlRbobXDPme4qv/Rc8+a1GbuUhE3m4JhQ= +github.com/DelineaXPM/tss-sdk-go/v2 v2.0.1 h1:/rzzzaBuj/FYTcbt8sYZ9IzlnENqcgh5zKqBhHiBBm4= +github.com/DelineaXPM/tss-sdk-go/v2 v2.0.1/go.mod h1:xz6FXP2Do88Vc5Hx7OamZgZC1W45yfmLy4+iDKxlGXo= github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/IBM/go-sdk-core/v5 v5.17.4 h1:VGb9+mRrnS2HpHZFM5hy4J6ppIWnwNrw0G+tLSgcJLc= diff --git a/hack/api-docs/mkdocs.yml b/hack/api-docs/mkdocs.yml index 700701001..2d5d8bffa 100644 --- a/hack/api-docs/mkdocs.yml +++ b/hack/api-docs/mkdocs.yml @@ -116,6 +116,7 @@ nav: - Cloak End 2 End Encrypted Secrets: provider/cloak.md - Scaleway: provider/scaleway.md - Delinea: provider/delinea.md + - Secret Server: provider/delinea.md - Passbolt: provider/passbolt.md - Pulumi ESC: provider/pulumi.md - Onboardbase: provider/onboardbase.md diff --git a/pkg/provider/register/register.go b/pkg/provider/register/register.go index 976a825cb..ab0e5407f 100644 --- a/pkg/provider/register/register.go +++ b/pkg/provider/register/register.go @@ -42,6 +42,7 @@ import ( _ "github.com/external-secrets/external-secrets/pkg/provider/passworddepot" _ "github.com/external-secrets/external-secrets/pkg/provider/pulumi" _ "github.com/external-secrets/external-secrets/pkg/provider/scaleway" + _ "github.com/external-secrets/external-secrets/pkg/provider/secretserver" _ "github.com/external-secrets/external-secrets/pkg/provider/senhasegura" _ "github.com/external-secrets/external-secrets/pkg/provider/vault" _ "github.com/external-secrets/external-secrets/pkg/provider/webhook" diff --git a/pkg/provider/secretserver/client.go b/pkg/provider/secretserver/client.go new file mode 100644 index 000000000..c3f19db3b --- /dev/null +++ b/pkg/provider/secretserver/client.go @@ -0,0 +1,147 @@ +/* +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 secretserver + +import ( + "context" + "encoding/json" + "errors" + "strconv" + + "github.com/DelineaXPM/tss-sdk-go/v2/server" + "github.com/tidwall/gjson" + corev1 "k8s.io/api/core/v1" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + "github.com/external-secrets/external-secrets/pkg/utils" +) + +type client struct { + api secretAPI +} + +var _ esv1beta1.SecretsClient = &client{} + +// GetSecret supports two types: +// 1. Get the secrets using the secret ID in ref.key i.e. key: 53974 +// 2. Get the secret using the secret "name" i.e. key: "secretNameHere" +// - Secret names must not contain spaces. +// - If using the secret "name" and multiple secrets are found ... +// the first secret in the array will be the secret returned. +// 3. get the full secret as json-encoded value +// by leaving the ref.Property empty. +// 4. get a specific value by using a key from the json formatted secret in Items.0.ItemValue. +// Nested values are supported by specifying a gjson expression +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 contains no fields + if secret.Fields == nil { + return nil, nil + } + jsonStr, err := json.Marshal(secret) + if err != nil { + return nil, err + } + // If no property is defined return the full secret as raw json + if ref.Property == "" { + return jsonStr, nil + } + // extract first "field" i.e. Items.0.ItemValue, data from secret using gjson + val := gjson.Get(string(jsonStr), "Items.0.ItemValue") + if !val.Exists() { + return nil, esv1beta1.NoSecretError{} + } + // extract specific value from data directly above using gjson + out := gjson.Get(val.String(), ref.Property) + if !out.Exists() { + return nil, esv1beta1.NoSecretError{} + } + + return []byte(out.String()), nil +} + +// Not supported at this time. +func (c *client) PushSecret(_ context.Context, _ *corev1.Secret, _ esv1beta1.PushSecretData) error { + return errors.New("pushing secrets is not supported by Secret Server at this time") +} + +// Not supported at this time. +func (c *client) DeleteSecret(_ context.Context, _ esv1beta1.PushSecretRemoteRef) error { + return errors.New("deleting secrets is not supported by Secret Server at this time") +} + +// Not supported at this time. +func (c *client) SecretExists(_ context.Context, _ esv1beta1.PushSecretRemoteRef) (bool, error) { + return false, errors.New("not implemented") +} + +// Not supported at this time. +func (c *client) Validate() (esv1beta1.ValidationResult, error) { + return esv1beta1.ValidationResultReady, nil +} + +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 + } + secretData := make(map[string]any) + + err = json.Unmarshal([]byte(secret.Fields[0].ItemValue), &secretData) + if err != nil { + return nil, err + } + + data := make(map[string][]byte) + for k, v := range secretData { + data[k], err = utils.GetByteValue(v) + if err != nil { + return nil, err + } + } + return data, nil +} + +// Not supported at this time. +func (c *client) GetAllSecrets(_ context.Context, _ esv1beta1.ExternalSecretFind) (map[string][]byte, error) { + return nil, errors.New("getting all secrets is not supported by Delinea Secret Server at this time") +} + +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) (*server.Secret, error) { + if ref.Version != "" { + return nil, errors.New("specifying a version is not supported") + } + id, err := strconv.Atoi(ref.Key) + if err != nil { + s, err := c.api.Secrets(ref.Key, "Name") + if err != nil { + return nil, err + } + if len(s) == 0 { + return nil, errors.New("unable to retrieve secret at this time") + } + + return &s[0], nil + } + return c.api.Secret(id) +} diff --git a/pkg/provider/secretserver/client_test.go b/pkg/provider/secretserver/client_test.go new file mode 100644 index 000000000..c338de70e --- /dev/null +++ b/pkg/provider/secretserver/client_test.go @@ -0,0 +1,162 @@ +/* +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 secretserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "testing" + + "github.com/DelineaXPM/tss-sdk-go/v2/server" + "github.com/stretchr/testify/assert" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" +) + +var ( + errNotFound = errors.New("not found") +) + +type fakeAPI struct { + secrets []*server.Secret +} + +func (f *fakeAPI) Secret(id int) (*server.Secret, error) { + for _, s := range f.secrets { + if s.ID == id { + return s, nil + } + } + return nil, errNotFound +} + +func (f *fakeAPI) Secrets(searchText, _ string) ([]server.Secret, error) { + secret := make([]server.Secret, 1) + for _, s := range f.secrets { + if s.Name == searchText { + secret[0] = *s + return secret, nil + } + } + return nil, errNotFound +} + +// createSecret assembles a server.Secret from file test_data.json. +func createSecret(id int, itemValue string) *server.Secret { + s, _ := getJSONData() + s.ID = id + s.Fields[0].ItemValue = itemValue + return s +} + +func getJSONData() (*server.Secret, error) { + var s = &server.Secret{} + jsonFile, err := os.Open("test_data.json") + if err != nil { + return nil, err + } + defer jsonFile.Close() + + byteValue, _ := io.ReadAll(jsonFile) + err = json.Unmarshal(byteValue, &s) + if err != nil { + return nil, err + } + return s, nil +} + +func newTestClient() esv1beta1.SecretsClient { + return &client{ + api: &fakeAPI{ + secrets: []*server.Secret{ + createSecret(1000, "{ \"user\": \"robertOppenheimer\", \"password\": \"badPassword\",\"server\":\"192.168.1.50\"}"), + createSecret(2000, "{ \"user\": \"helloWorld\", \"password\": \"badPassword\",\"server\":[ \"192.168.1.50\",\"192.168.1.51\"] }"), + createSecret(3000, "{ \"user\": \"chuckTesta\", \"password\": \"badPassword\",\"server\":\"192.168.1.50\"}"), + }, + }, + } +} + +func TestGetSecret(t *testing.T) { + ctx := context.Background() + c := newTestClient() + s, _ := getJSONData() + jsonStr, _ := json.Marshal(s) + + testCases := map[string]struct { + ref esv1beta1.ExternalSecretDataRemoteRef + want []byte + err error + }{ + "incorrect key returns nil and error": { + ref: esv1beta1.ExternalSecretDataRemoteRef{ + Key: "0", + }, + want: []byte(nil), + err: errNotFound, + }, + "key = 'secret name' and user property returns a single value": { + ref: esv1beta1.ExternalSecretDataRemoteRef{ + Key: "ESO-test-secret", + Property: "user", + }, + want: []byte(`robertOppenheimer`), + }, + "key and password property returns a single value": { + ref: esv1beta1.ExternalSecretDataRemoteRef{ + Key: "1000", + Property: "password", + }, + want: []byte(`badPassword`), + }, + "key and nested property returns a single value": { + ref: esv1beta1.ExternalSecretDataRemoteRef{ + Key: "2000", + Property: "server.1", + }, + want: []byte(`192.168.1.51`), + }, + "existent key with non-existing propery": { + ref: esv1beta1.ExternalSecretDataRemoteRef{ + Key: "3000", + Property: "foo.bar", + }, + err: esv1beta1.NoSecretError{}, + }, + "existent 'name' key with no propery": { + ref: esv1beta1.ExternalSecretDataRemoteRef{ + Key: "1000", + }, + want: jsonStr, + }, + } + + 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/secretserver/provider.go b/pkg/provider/secretserver/provider.go new file mode 100644 index 000000000..e44701316 --- /dev/null +++ b/pkg/provider/secretserver/provider.go @@ -0,0 +1,179 @@ +/* +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 secretserver + +import ( + "context" + "errors" + + "github.com/DelineaXPM/tss-sdk-go/v2/server" + kubeClient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + "github.com/external-secrets/external-secrets/pkg/utils" + "github.com/external-secrets/external-secrets/pkg/utils/resolvers" +) + +var ( + errEmptyUserName = errors.New("username must not be empty") + errEmptyPassword = errors.New("password must be set") + errEmptyServerURL = errors.New("serverURL 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 secret server provider") + errClusterStoreRequiresNamespace = errors.New("when using a ClusterSecretStore, namespaces must be explicitly set") + errMissingSecretName = errors.New("must specify a secret name") + + errMissingSecretKey = errors.New("must specify a secret key") +) + +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 + } + username, err := loadConfigSecret(ctx, store.GetKind(), cfg.Username, kube, namespace) + if err != nil { + return nil, err + } + password, err := loadConfigSecret(ctx, store.GetKind(), cfg.Password, kube, namespace) + if err != nil { + return nil, err + } + + secretServer, err := server.New(server.Configuration{ + Credentials: server.UserCredential{ + Username: username, + Password: password, + }, + ServerURL: cfg.ServerURL, + }) + if err != nil { + return nil, err + } + + return &client{ + api: secretServer, + }, nil +} + +func loadConfigSecret( + ctx context.Context, + storeKind string, + ref *esv1beta1.SecretServerProviderRef, + kube kubeClient.Client, + namespace string) (string, error) { + if ref.SecretRef == nil { + return ref.Value, nil + } + if err := validateSecretRef(ref); err != nil { + return "", err + } + return resolvers.SecretKeyRef(ctx, kube, storeKind, namespace, ref.SecretRef) +} + +func validateStoreSecretRef(store esv1beta1.GenericStore, ref *esv1beta1.SecretServerProviderRef) error { + if ref.SecretRef != nil { + if err := utils.ValidateReferentSecretSelector(store, *ref.SecretRef); err != nil { + return err + } + } + return validateSecretRef(ref) +} + +func validateSecretRef(ref *esv1beta1.SecretServerProviderRef) 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.SecretServerProvider) bool { + if cfg.Username.SecretRef != nil && cfg.Username.SecretRef.Namespace == nil { + return true + } + if cfg.Password.SecretRef != nil && cfg.Password.SecretRef.Namespace == nil { + return true + } + return false +} + +func getConfig(store esv1beta1.GenericStore) (*esv1beta1.SecretServerProvider, error) { + if store == nil { + return nil, errMissingStore + } + storeSpec := store.GetSpec() + + if storeSpec == nil || storeSpec.Provider == nil || storeSpec.Provider.SecretServer == nil { + return nil, errInvalidSpec + } + cfg := storeSpec.Provider.SecretServer + + if cfg.Username == nil { + return nil, errEmptyUserName + } + if cfg.Password == nil { + return nil, errEmptyPassword + } + if cfg.ServerURL == "" { + return nil, errEmptyServerURL + } + + err := validateStoreSecretRef(store, cfg.Username) + if err != nil { + return nil, err + } + err = validateStoreSecretRef(store, cfg.Password) + if err != nil { + return nil, err + } + return cfg, nil +} + +func (p *Provider) ValidateStore(store esv1beta1.GenericStore) (admission.Warnings, error) { + _, err := getConfig(store) + return nil, err +} + +func init() { + esv1beta1.Register(&Provider{}, &esv1beta1.SecretStoreProvider{ + SecretServer: &esv1beta1.SecretServerProvider{}, + }) +} diff --git a/pkg/provider/secretserver/provider_test.go b/pkg/provider/secretserver/provider_test.go new file mode 100644 index 000000000..53a14fc4f --- /dev/null +++ b/pkg/provider/secretserver/provider_test.go @@ -0,0 +1,351 @@ +/* +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 secretserver + +import ( + "context" + "math/rand" + "testing" + + "github.com/DelineaXPM/tss-sdk-go/v2/server" + "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.SecretServerProvider + want bool + }{ + "true when Username references a secret without explicit namespace": { + cfg: esv1beta1.SecretServerProvider{ + Username: &esv1beta1.SecretServerProviderRef{ + SecretRef: &v1.SecretKeySelector{Name: "foo"}, + }, + Password: &esv1beta1.SecretServerProviderRef{SecretRef: nil}, + }, + want: true, + }, + "true when password references a secret without explicit namespace": { + cfg: esv1beta1.SecretServerProvider{ + Username: &esv1beta1.SecretServerProviderRef{SecretRef: nil}, + Password: &esv1beta1.SecretServerProviderRef{ + SecretRef: &v1.SecretKeySelector{Name: "foo"}, + }, + }, + want: true, + }, + "false when neither Username or Password reference a secret": { + cfg: esv1beta1.SecretServerProvider{ + Username: &esv1beta1.SecretServerProviderRef{SecretRef: nil}, + Password: &esv1beta1.SecretServerProviderRef{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.SecretServerProviderRef{ + SecretRef: &v1.SecretKeySelector{Name: "foo"}, Value: "foo", + } + testURL := "https://example.com" + + tests := map[string]struct { + cfg esv1beta1.SecretServerProvider + want error + }{ + "invalid without username": { + cfg: esv1beta1.SecretServerProvider{ + Username: nil, + Password: validSecretRefUsingValue, + ServerURL: testURL, + }, + want: errEmptyUserName, + }, + "invalid without password": { + cfg: esv1beta1.SecretServerProvider{ + Username: validSecretRefUsingValue, + Password: nil, + ServerURL: testURL, + }, + want: errEmptyPassword, + }, + "invalid without serverURL": { + cfg: esv1beta1.SecretServerProvider{ + Username: validSecretRefUsingValue, + Password: validSecretRefUsingValue, + /*ServerURL: testURL,*/ + }, + want: errEmptyServerURL, + }, + "invalid with ambiguous Username": { + cfg: esv1beta1.SecretServerProvider{ + Username: ambiguousSecretRef, + Password: validSecretRefUsingValue, + ServerURL: testURL, + }, + want: errSecretRefAndValueConflict, + }, + "invalid with ambiguous Password": { + cfg: esv1beta1.SecretServerProvider{ + Username: validSecretRefUsingValue, + Password: ambiguousSecretRef, + ServerURL: testURL, + }, + want: errSecretRefAndValueConflict, + }, + "invalid with invalid Username": { + cfg: esv1beta1.SecretServerProvider{ + Username: makeSecretRefUsingValue(""), + Password: validSecretRefUsingValue, + ServerURL: testURL, + }, + want: errSecretRefAndValueMissing, + }, + "invalid with invalid Password": { + cfg: esv1beta1.SecretServerProvider{ + Username: validSecretRefUsingValue, + Password: makeSecretRefUsingValue(""), + ServerURL: testURL, + }, + want: errSecretRefAndValueMissing, + }, + "valid with tenant/clientID/clientSecret": { + cfg: esv1beta1.SecretServerProvider{ + Username: validSecretRefUsingValue, + Password: validSecretRefUsingValue, + ServerURL: testURL, + }, + want: nil, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + s := esv1beta1.SecretStore{ + Spec: esv1beta1.SecretStoreSpec{ + Provider: &esv1beta1.SecretStoreProvider{ + SecretServer: &tc.cfg, + }, + }, + } + p := &Provider{} + _, got := p.ValidateStore(&s) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestNewClient(t *testing.T) { + userNameKey := "username" + userNameValue := "foo" + passwordKey := "password" + passwordValue := generateRandomString() + + clientSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default"}, + Data: map[string][]byte{ + userNameKey: []byte(userNameValue), + passwordKey: []byte(passwordValue), + }, + } + + validProvider := &esv1beta1.SecretServerProvider{ + Username: makeSecretRefUsingRef(clientSecret.Name, userNameKey), + Password: makeSecretRefUsingRef(clientSecret.Name, passwordKey), + ServerURL: "https://example.com", + } + + tests := map[string]struct { + store esv1beta1.GenericStore // leave nil for namespaced store + provider *esv1beta1.SecretServerProvider // 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{ + SecretServer: validProvider, + }, + }, + }, + errCheck: func(t *testing.T, err error) { + assert.ErrorIs(t, err, errClusterStoreRequiresNamespace) + }, + }, + "dangling password ref": { + provider: &esv1beta1.SecretServerProvider{ + Username: validProvider.Username, + Password: makeSecretRefUsingRef("typo", passwordKey), + ServerURL: validProvider.ServerURL, + }, + kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(), + errCheck: func(t *testing.T, err error) { + assert.True(t, kubeErrors.IsNotFound(err)) + }, + }, + "dangling username ref": { + provider: &esv1beta1.SecretServerProvider{ + Username: makeSecretRefUsingRef("typo", userNameKey), + Password: validProvider.Password, + ServerURL: validProvider.ServerURL, + }, + 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.SecretServerProvider{ + Username: makeSecretRefUsingRef("", userNameKey), + Password: validProvider.Password, + ServerURL: validProvider.ServerURL, + }, + kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(), + errCheck: func(t *testing.T, err error) { + assert.ErrorIs(t, err, errMissingSecretName) + }, + }, + "secret ref without key": { + provider: &esv1beta1.SecretServerProvider{ + Username: validProvider.Password, + Password: makeSecretRefUsingRef(clientSecret.Name, ""), + ServerURL: validProvider.ServerURL, + }, + 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.SecretServerProvider{ + Username: makeSecretRefUsingRef(clientSecret.Name, "typo"), + Password: makeSecretRefUsingRef(clientSecret.Name, passwordKey), + ServerURL: validProvider.ServerURL, + }, + kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(), + errCheck: func(t *testing.T, err error) { + assert.EqualError(t, err, "cannot find secret data for key: \"typo\"") + }, + }, + "valid secret refs": { + provider: validProvider, + kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(), + }, + "secret values": { + provider: &esv1beta1.SecretServerProvider{ + Username: makeSecretRefUsingValue(userNameValue), + Password: makeSecretRefUsingValue(passwordValue), + ServerURL: validProvider.ServerURL, + }, + kube: clientfake.NewClientBuilder().WithObjects(clientSecret).Build(), + }, + "cluster secret store": { + store: &esv1beta1.ClusterSecretStore{ + TypeMeta: metav1.TypeMeta{Kind: esv1beta1.ClusterSecretStoreKind}, + Spec: esv1beta1.SecretStoreSpec{ + Provider: &esv1beta1.SecretStoreProvider{ + SecretServer: &esv1beta1.SecretServerProvider{ + Username: makeSecretRefUsingNamespacedRef(clientSecret.Namespace, clientSecret.Name, userNameKey), + Password: makeSecretRefUsingNamespacedRef(clientSecret.Namespace, clientSecret.Name, passwordKey), + ServerURL: validProvider.ServerURL, + }, + }, + }, + }, + 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{ + SecretServer: 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) + secretServerClient, ok := delineaClient.api.(*server.Server) + assert.True(t, ok) + assert.Equal(t, server.UserCredential{ + Username: userNameValue, + Password: passwordValue, + }, secretServerClient.Configuration.Credentials) + } else { + assert.Nil(t, sc) + tc.errCheck(t, err) + } + }) + } +} + +func makeSecretRefUsingNamespacedRef(namespace, name, key string) *esv1beta1.SecretServerProviderRef { + return &esv1beta1.SecretServerProviderRef{ + SecretRef: &v1.SecretKeySelector{Namespace: utils.Ptr(namespace), Name: name, Key: key}, + } +} + +func makeSecretRefUsingValue(val string) *esv1beta1.SecretServerProviderRef { + return &esv1beta1.SecretServerProviderRef{Value: val} +} + +func makeSecretRefUsingRef(name, key string) *esv1beta1.SecretServerProviderRef { + return &esv1beta1.SecretServerProviderRef{ + SecretRef: &v1.SecretKeySelector{Name: name, Key: key}, + } +} + +func generateRandomString() string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + b := make([]rune, 10) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + + return string(b) +} diff --git a/pkg/provider/secretserver/secret_api.go b/pkg/provider/secretserver/secret_api.go new file mode 100644 index 000000000..f8beacff6 --- /dev/null +++ b/pkg/provider/secretserver/secret_api.go @@ -0,0 +1,26 @@ +/* +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 secretserver + +import ( + "github.com/DelineaXPM/tss-sdk-go/v2/server" +) + +// secretAPI represents the subset of the Secret Server API +// which is supported by tss-sdk-go/v2. +type secretAPI interface { + Secret(id int) (*server.Secret, error) + Secrets(searchText, field string) ([]server.Secret, error) +} diff --git a/pkg/provider/secretserver/test_data.json b/pkg/provider/secretserver/test_data.json new file mode 100644 index 000000000..611e5906d --- /dev/null +++ b/pkg/provider/secretserver/test_data.json @@ -0,0 +1,38 @@ +{ +"Name": "ESO-test-secret", +"FolderID": 73, +"ID": 1000, +"SiteID": 1, +"SecretTemplateID": 6098, +"SecretPolicyID": -1, +"PasswordTypeWebScriptID": -1, +"LauncherConnectAsSecretID": -1, +"CheckOutIntervalMinutes": -1, +"Active": true, +"CheckedOut": false, +"CheckOutEnabled": false, +"AutoChangeEnabled": false, +"CheckOutChangePasswordEnabled": false, +"DelayIndexing": false, +"EnableInheritPermissions": false, +"EnableInheritSecretPolicy": false, +"ProxyEnabled": false, +"RequiresComment": false, +"SessionRecordingEnabled": false, +"WebLauncherRequiresIncognitoMode": false, +"Items": [ + { + "ItemID": 286259, + "FieldID": 439, + "FileAttachmentID": 0, + "FieldName": "Data", + "Slug": "data", + "FieldDescription": "json text field", + "Filename": "", + "ItemValue": "{ \"user\": \"robertOppenheimer\", \"password\": \"badPassword\",\"server\":\"192.168.1.50\"}", + "IsFile": false, + "IsNotes": false, + "IsPassword": false + } +] +}