From e2ec08c539aa057b2f46bbe5bbd02ce0cc16e563 Mon Sep 17 00:00:00 2001
From: Dmitry Ivanov
Date: Fri, 17 May 2024 15:17:10 +0300
Subject: [PATCH 1/2] feat: add Cloud.ru provider
Add a new SecretManager provider, which
integrates with cloud.ru API and
allows to interact with stored secrets.
---
.../v1beta1/secretstore_cloudru_types.go | 44 +++
.../v1beta1/secretstore_types.go | 4 +
.../v1beta1/zz_generated.deepcopy.go | 58 ++++
...ternal-secrets.io_clustersecretstores.yaml | 63 ++++
.../external-secrets.io_secretstores.yaml | 63 ++++
deploy/crds/bundle.yaml | 114 ++++++
e2e/go.mod | 8 +-
e2e/go.sum | 4 +
go.mod | 10 +-
go.sum | 20 +-
.../secretmanager/adapter/csm_client.go | 181 ++++++++++
pkg/provider/cloudru/secretmanager/client.go | 189 ++++++++++
.../cloudru/secretmanager/client_test.go | 328 ++++++++++++++++++
.../cloudru/secretmanager/endpoints.go | 73 ++++
.../cloudru/secretmanager/fake/fake.go | 45 +++
.../cloudru/secretmanager/provider.go | 202 +++++++++++
.../cloudru/secretmanager/resolver.go | 51 +++
pkg/provider/register/register.go | 1 +
18 files changed, 1442 insertions(+), 16 deletions(-)
create mode 100644 apis/externalsecrets/v1beta1/secretstore_cloudru_types.go
create mode 100644 pkg/provider/cloudru/secretmanager/adapter/csm_client.go
create mode 100644 pkg/provider/cloudru/secretmanager/client.go
create mode 100644 pkg/provider/cloudru/secretmanager/client_test.go
create mode 100644 pkg/provider/cloudru/secretmanager/endpoints.go
create mode 100644 pkg/provider/cloudru/secretmanager/fake/fake.go
create mode 100644 pkg/provider/cloudru/secretmanager/provider.go
create mode 100644 pkg/provider/cloudru/secretmanager/resolver.go
diff --git a/apis/externalsecrets/v1beta1/secretstore_cloudru_types.go b/apis/externalsecrets/v1beta1/secretstore_cloudru_types.go
new file mode 100644
index 00000000000..5760c12b551
--- /dev/null
+++ b/apis/externalsecrets/v1beta1/secretstore_cloudru_types.go
@@ -0,0 +1,44 @@
+/*
+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"
+)
+
+// CSMAuth contains a secretRef for credentials.
+type CSMAuth struct {
+ // +optional
+ SecretRef *CSMAuthSecretRef `json:"secretRef,omitempty"`
+}
+
+// CSMAuthSecretRef holds secret references for Cloud.ru credentials.
+type CSMAuthSecretRef struct {
+ // The AccessKeyID is used for authentication
+ AccessKeyID esmeta.SecretKeySelector `json:"accessKeyIDSecretRef"`
+ // The AccessKeySecret is used for authentication
+ AccessKeySecret esmeta.SecretKeySelector `json:"accessKeySecretSecretRef"`
+}
+
+// CloudruSMProvider configures a store to sync secrets using the Cloud.ru Secret Manager provider.
+type CloudruSMProvider struct {
+ // DiscoveryURL is used to connect to the Cloud.ru product APIs.
+ // +optional
+ DiscoveryURL string `json:"discoveryURL"`
+ Auth CSMAuth `json:"auth"`
+
+ // ProductInstanceID is the service, which the secrets are stored in.
+ ProductInstanceID string `json:"productInstanceID,omitempty"`
+}
diff --git a/apis/externalsecrets/v1beta1/secretstore_types.go b/apis/externalsecrets/v1beta1/secretstore_types.go
index b05d32e4471..82d96763608 100644
--- a/apis/externalsecrets/v1beta1/secretstore_types.go
+++ b/apis/externalsecrets/v1beta1/secretstore_types.go
@@ -163,6 +163,10 @@ type SecretStoreProvider struct {
// +optional
Passbolt *PassboltProvider `json:"passbolt,omitempty"`
+
+ // CloudruSM configures this store to sync secrets using the Cloud.ru Secret Manager provider
+ // +optional
+ CloudruSM *CloudruSMProvider `json:"cloudrusm,omitempty"`
}
type CAProviderType string
diff --git a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go
index 97259d5109e..4f3a7e08a64 100644
--- a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go
+++ b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go
@@ -406,6 +406,43 @@ func (in *CAProvider) DeepCopy() *CAProvider {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CSMAuth) DeepCopyInto(out *CSMAuth) {
+ *out = *in
+ if in.SecretRef != nil {
+ in, out := &in.SecretRef, &out.SecretRef
+ *out = new(CSMAuthSecretRef)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CSMAuth.
+func (in *CSMAuth) DeepCopy() *CSMAuth {
+ if in == nil {
+ return nil
+ }
+ out := new(CSMAuth)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CSMAuthSecretRef) DeepCopyInto(out *CSMAuthSecretRef) {
+ *out = *in
+ in.AccessKeyID.DeepCopyInto(&out.AccessKeyID)
+ in.AccessKeySecret.DeepCopyInto(&out.AccessKeySecret)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CSMAuthSecretRef.
+func (in *CSMAuthSecretRef) DeepCopy() *CSMAuthSecretRef {
+ if in == nil {
+ return nil
+ }
+ out := new(CSMAuthSecretRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CertAuth) DeepCopyInto(out *CertAuth) {
*out = *in
@@ -475,6 +512,22 @@ func (in *ChefProvider) DeepCopy() *ChefProvider {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CloudruSMProvider) DeepCopyInto(out *CloudruSMProvider) {
+ *out = *in
+ in.Auth.DeepCopyInto(&out.Auth)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudruSMProvider.
+func (in *CloudruSMProvider) DeepCopy() *CloudruSMProvider {
+ if in == nil {
+ return nil
+ }
+ out := new(CloudruSMProvider)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ClusterExternalSecret) DeepCopyInto(out *ClusterExternalSecret) {
*out = *in
@@ -2300,6 +2353,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) {
*out = new(PassboltProvider)
(*in).DeepCopyInto(*out)
}
+ if in.CloudruSM != nil {
+ in, out := &in.CloudruSM, &out.CloudruSM
+ *out = new(CloudruSMProvider)
+ (*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 11eac0c40c6..12b6163b8a1 100644
--- a/config/crds/bases/external-secrets.io_clustersecretstores.yaml
+++ b/config/crds/bases/external-secrets.io_clustersecretstores.yaml
@@ -2318,6 +2318,69 @@ spec:
- serverUrl
- username
type: object
+ cloudrusm:
+ description: CloudruSM configures this store to sync secrets using
+ the Cloud.ru Secret Manager provider
+ properties:
+ auth:
+ description: CSMAuth contains a secretRef for credentials.
+ properties:
+ secretRef:
+ description: CSMAuthSecretRef holds secret references
+ for Cloud.ru credentials.
+ properties:
+ accessKeyIDSecretRef:
+ description: The AccessKeyID is used for authentication
+ 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
+ accessKeySecretSecretRef:
+ description: The AccessKeySecret is used for authentication
+ 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
+ required:
+ - accessKeyIDSecretRef
+ - accessKeySecretSecretRef
+ type: object
+ type: object
+ discoveryURL:
+ description: DiscoveryURL is used to connect to the Cloud.ru
+ product APIs.
+ type: string
+ productInstanceID:
+ description: ProductInstanceID is the service, which the secrets
+ are stored in.
+ type: string
+ required:
+ - auth
+ type: object
conjur:
description: Conjur configures this store to sync secrets using
conjur provider
diff --git a/config/crds/bases/external-secrets.io_secretstores.yaml b/config/crds/bases/external-secrets.io_secretstores.yaml
index b4e19aaabea..c10a668a0d0 100644
--- a/config/crds/bases/external-secrets.io_secretstores.yaml
+++ b/config/crds/bases/external-secrets.io_secretstores.yaml
@@ -2318,6 +2318,69 @@ spec:
- serverUrl
- username
type: object
+ cloudrusm:
+ description: CloudruSM configures this store to sync secrets using
+ the Cloud.ru Secret Manager provider
+ properties:
+ auth:
+ description: CSMAuth contains a secretRef for credentials.
+ properties:
+ secretRef:
+ description: CSMAuthSecretRef holds secret references
+ for Cloud.ru credentials.
+ properties:
+ accessKeyIDSecretRef:
+ description: The AccessKeyID is used for authentication
+ 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
+ accessKeySecretSecretRef:
+ description: The AccessKeySecret is used for authentication
+ 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
+ required:
+ - accessKeyIDSecretRef
+ - accessKeySecretSecretRef
+ type: object
+ type: object
+ discoveryURL:
+ description: DiscoveryURL is used to connect to the Cloud.ru
+ product APIs.
+ type: string
+ productInstanceID:
+ description: ProductInstanceID is the service, which the secrets
+ are stored in.
+ type: string
+ required:
+ - auth
+ type: object
conjur:
description: Conjur configures this store to sync secrets using
conjur provider
diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml
index f3fb0942a1f..0494241614a 100644
--- a/deploy/crds/bundle.yaml
+++ b/deploy/crds/bundle.yaml
@@ -2821,6 +2821,63 @@ spec:
- serverUrl
- username
type: object
+ cloudrusm:
+ description: CloudruSM configures this store to sync secrets using the Cloud.ru Secret Manager provider
+ properties:
+ auth:
+ description: CSMAuth contains a secretRef for credentials.
+ properties:
+ secretRef:
+ description: CSMAuthSecretRef holds secret references for Cloud.ru credentials.
+ properties:
+ accessKeyIDSecretRef:
+ description: The AccessKeyID is used for authentication
+ 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
+ accessKeySecretSecretRef:
+ description: The AccessKeySecret is used for authentication
+ 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
+ required:
+ - accessKeyIDSecretRef
+ - accessKeySecretSecretRef
+ type: object
+ type: object
+ discoveryURL:
+ description: DiscoveryURL is used to connect to the Cloud.ru product APIs.
+ type: string
+ productInstanceID:
+ description: ProductInstanceID is the service, which the secrets are stored in.
+ type: string
+ required:
+ - auth
+ type: object
conjur:
description: Conjur configures this store to sync secrets using conjur provider
properties:
@@ -8166,6 +8223,63 @@ spec:
- serverUrl
- username
type: object
+ cloudrusm:
+ description: CloudruSM configures this store to sync secrets using the Cloud.ru Secret Manager provider
+ properties:
+ auth:
+ description: CSMAuth contains a secretRef for credentials.
+ properties:
+ secretRef:
+ description: CSMAuthSecretRef holds secret references for Cloud.ru credentials.
+ properties:
+ accessKeyIDSecretRef:
+ description: The AccessKeyID is used for authentication
+ 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
+ accessKeySecretSecretRef:
+ description: The AccessKeySecret is used for authentication
+ 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
+ required:
+ - accessKeyIDSecretRef
+ - accessKeySecretSecretRef
+ type: object
+ type: object
+ discoveryURL:
+ description: DiscoveryURL is used to connect to the Cloud.ru product APIs.
+ type: string
+ productInstanceID:
+ description: ProductInstanceID is the service, which the secrets are stored in.
+ type: string
+ required:
+ - auth
+ type: object
conjur:
description: Conjur configures this store to sync secrets using conjur provider
properties:
diff --git a/e2e/go.mod b/e2e/go.mod
index ecb8e5ca253..62dc0aaaacb 100644
--- a/e2e/go.mod
+++ b/e2e/go.mod
@@ -195,10 +195,10 @@ require (
golang.org/x/tools v0.20.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 // indirect
- google.golang.org/grpc v1.63.2 // indirect
- google.golang.org/protobuf v1.34.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
+ google.golang.org/grpc v1.64.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/e2e/go.sum b/e2e/go.sum
index 855723dbc51..ee46f1d4a07 100644
--- a/e2e/go.sum
+++ b/e2e/go.sum
@@ -862,8 +862,10 @@ google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6 h1:MTmrc2F5TZKDKXi
google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6/go.mod h1:2ROWwqCIx97Y7CSyp11xB8fori0wzvD6+gbacaf5c8I=
google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6 h1:DTJM0R8LECCgFeUwApvcEJHz85HLagW8uRENYxHh1ww=
google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6/go.mod h1:10yRODfgim2/T8csjQsMPgZOMvtytXKTDRzH6HRGzRw=
+google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 h1:DujSIu+2tC9Ht0aPNA7jgj23Iq8Ewi5sgkQ++wdvonE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -885,6 +887,7 @@ google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
+google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -900,6 +903,7 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
diff --git a/go.mod b/go.mod
index b4f816dac9d..ed1b945f5f4 100644
--- a/go.mod
+++ b/go.mod
@@ -45,7 +45,7 @@ require (
golang.org/x/oauth2 v0.20.0
google.golang.org/api v0.177.0
google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6
- google.golang.org/grpc v1.63.2
+ google.golang.org/grpc v1.64.0
gopkg.in/yaml.v3 v3.0.1
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919
k8s.io/api v0.30.0
@@ -72,6 +72,8 @@ require (
github.com/alibabacloud-go/tea-utils/v2 v2.0.5
github.com/aliyun/credentials-go v1.3.3
github.com/avast/retry-go/v4 v4.6.0
+ github.com/cloudru-tech/iam-sdk v1.0.2
+ github.com/cloudru-tech/secret-manager-sdk v1.0.0
github.com/cyberark/conjur-api-go v0.11.2
github.com/fortanix/sdkms-client-go v0.4.0
github.com/go-openapi/strfmt v0.23.0
@@ -185,8 +187,8 @@ require (
go.opentelemetry.io/otel/metric v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f // indirect
lukechampine.com/frand v1.4.2 // indirect
@@ -295,7 +297,7 @@ require (
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.20.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
- google.golang.org/protobuf v1.34.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/go.sum b/go.sum
index 4e672ec6a9d..8187643b985 100644
--- a/go.sum
+++ b/go.sum
@@ -247,6 +247,10 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
+github.com/cloudru-tech/iam-sdk v1.0.2 h1:ZgjOkH1BUoYuN2qbR30Ashsf5ppEz3paP9Bd/8QpC4c=
+github.com/cloudru-tech/iam-sdk v1.0.2/go.mod h1:0JSJ20U5lnOGpEaVYiUss32REmAQHUYsGut7s+MPA6s=
+github.com/cloudru-tech/secret-manager-sdk v1.0.0 h1:grBzhv/rzZeYpebFvPaK+6UNNZvAGTuIIdEagLD6NJ8=
+github.com/cloudru-tech/secret-manager-sdk v1.0.0/go.mod h1:UdfVRiOlWjb/QZ8HhbotzyklPU2+ddeemKl2BXuL3oQ=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -1243,10 +1247,10 @@ google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQ
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6 h1:MTmrc2F5TZKDKXigcZetYkH04YwqtOPEQJwh4PPOgfk=
google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6/go.mod h1:2ROWwqCIx97Y7CSyp11xB8fori0wzvD6+gbacaf5c8I=
-google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6 h1:DTJM0R8LECCgFeUwApvcEJHz85HLagW8uRENYxHh1ww=
-google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6/go.mod h1:10yRODfgim2/T8csjQsMPgZOMvtytXKTDRzH6HRGzRw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 h1:DujSIu+2tC9Ht0aPNA7jgj23Iq8Ewi5sgkQ++wdvonE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
+google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE=
+google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1269,8 +1273,8 @@ google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
-google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
-google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
+google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
+google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1285,8 +1289,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
-google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/provider/cloudru/secretmanager/adapter/csm_client.go b/pkg/provider/cloudru/secretmanager/adapter/csm_client.go
new file mode 100644
index 00000000000..a6182686205
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/adapter/csm_client.go
@@ -0,0 +1,181 @@
+/*
+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 adapter
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ iamAuthV1 "github.com/cloudru-tech/iam-sdk/api/auth/v1"
+ smsV1 "github.com/cloudru-tech/secret-manager-sdk/api/v1"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+)
+
+const (
+ // defaultBatchSize is the maximum number of secrets to list in a single request.
+ defaultBatchSize = 100
+)
+
+// CredentialsResolver returns the actual client credentials.
+type CredentialsResolver interface {
+ Resolve(ctx context.Context) (*Credentials, error)
+}
+
+// APIClient - Cloudru Secret Manager Service Client
+type APIClient struct {
+ cr CredentialsResolver
+
+ iamClient iamAuthV1.AuthServiceClient
+ smsClient smsV1.SecretManagerServiceClient
+
+ mu sync.Mutex
+ accessToken string
+ accessTokenExpiresAt time.Time
+}
+
+// ListSecretsRequest is a request to list secrets.
+type ListSecretsRequest struct {
+ ParentID string
+ Labels map[string]string
+ NameExact string
+ NameRegex string
+
+ Limit int32
+ Offset int32
+}
+
+// Credentials holds the keyID and secret for the CSM client.
+type Credentials struct {
+ KeyID string
+ Secret string
+}
+
+// NewCredentials creates a new Credentials object.
+func NewCredentials(kid, secret string) (*Credentials, error) {
+ if kid == "" || secret == "" {
+ return nil, errors.New("keyID and secret must be provided")
+ }
+
+ return &Credentials{KeyID: kid, Secret: secret}, nil
+}
+
+// NewAPIClient creates a new grpc SecretManager client.
+func NewAPIClient(cr CredentialsResolver, iamClient iamAuthV1.AuthServiceClient, client smsV1.SecretManagerServiceClient) *APIClient {
+ return &APIClient{
+ cr: cr,
+ iamClient: iamClient,
+ smsClient: client,
+ }
+}
+
+func (c *APIClient) ListSecrets(ctx context.Context, req *ListSecretsRequest) ([]*smsV1.Secret, error) {
+ listReq := &smsV1.ListSecretsRequest{
+ ParentId: req.ParentID,
+ Page: &smsV1.Page{Limit: defaultBatchSize, Offset: 0},
+ Labels: req.Labels,
+ }
+ switch {
+ case req.NameExact != "":
+ listReq.Name = &smsV1.ListSecretsRequest_Exact{Exact: req.NameExact}
+ case req.NameRegex != "":
+ listReq.Name = &smsV1.ListSecretsRequest_Regex{Regex: req.NameRegex}
+ }
+
+ if req.Limit != 0 {
+ listReq.Page.Limit = req.Limit
+ }
+ if req.Offset != 0 {
+ listReq.Page.Offset = req.Offset
+ }
+
+ var err error
+ ctx, err = c.authCtx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("unauthorized: %w", err)
+ }
+
+ resp, err := c.smsClient.ListSecrets(ctx, listReq)
+ if err != nil {
+ return nil, err
+ }
+
+ return resp.Secrets, nil
+}
+
+func (c *APIClient) AccessSecretVersion(ctx context.Context, id, version string) ([]byte, error) {
+ var err error
+ ctx, err = c.authCtx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("unauthorized: %w", err)
+ }
+
+ req := &smsV1.AccessSecretVersionRequest{
+ SecretId: id,
+ SecretVersionId: version,
+ }
+ secret, err := c.smsClient.AccessSecretVersion(ctx, req)
+ if err != nil {
+ st, _ := status.FromError(err)
+ if st.Code() == codes.NotFound {
+ return nil, fmt.Errorf("secret '%s %s' not found", id, version)
+ }
+
+ return nil, fmt.Errorf("failed to get the secret by id '%s v%s': %s", id, version, err)
+ }
+
+ return secret.GetData().GetValue(), nil
+}
+
+func (c *APIClient) authCtx(ctx context.Context) (context.Context, error) {
+ md, ok := metadata.FromOutgoingContext(ctx)
+ if !ok {
+ md = metadata.New(map[string]string{})
+ }
+ token, err := c.getOrCreateToken(ctx)
+ if err != nil {
+ return ctx, fmt.Errorf("fetch IAM access token: %w", err)
+ }
+
+ md.Set("authorization", "Bearer "+token)
+ return metadata.NewOutgoingContext(ctx, md), nil
+}
+
+func (c *APIClient) getOrCreateToken(ctx context.Context) (string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.accessToken != "" && c.accessTokenExpiresAt.After(time.Now()) {
+ return c.accessToken, nil
+ }
+
+ creds, err := c.cr.Resolve(ctx)
+ if err != nil {
+ return "", fmt.Errorf("resolve API credentials: %w", err)
+ }
+
+ resp, err := c.iamClient.GetToken(ctx, &iamAuthV1.GetTokenRequest{KeyId: creds.KeyID, Secret: creds.Secret})
+ if err != nil {
+ return "", fmt.Errorf("get access token: %w", err)
+ }
+
+ c.accessToken = resp.AccessToken
+ c.accessTokenExpiresAt = time.Now().Add(time.Second * time.Duration(resp.ExpiresIn))
+ return c.accessToken, nil
+}
diff --git a/pkg/provider/cloudru/secretmanager/client.go b/pkg/provider/cloudru/secretmanager/client.go
new file mode 100644
index 00000000000..fdee7124d7e
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/client.go
@@ -0,0 +1,189 @@
+/*
+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 secretmanager
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ smsV1 "github.com/cloudru-tech/secret-manager-sdk/api/v1"
+ "github.com/google/uuid"
+ "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/provider/cloudru/secretmanager/adapter"
+ "github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+// SecretProvider is an API client for the Cloud.ru Secret Manager.
+type SecretProvider interface {
+ // ListSecrets lists secrets by the given request.
+ ListSecrets(ctx context.Context, req *adapter.ListSecretsRequest) ([]*smsV1.Secret, error)
+ // AccessSecretVersion gets the secret by the given request.
+ AccessSecretVersion(ctx context.Context, id, version string) ([]byte, error)
+}
+
+// Client is a client for the Cloud.ru Secret Manager.
+type Client struct {
+ apiClient SecretProvider
+
+ productInstanceID string
+}
+
+// GetSecret gets the secret by the remote reference.
+func (c *Client) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) {
+ secret, err := c.accessSecret(ctx, ref.Key, ref.Version)
+ if err != nil {
+ return nil, err
+ }
+
+ prop := strings.TrimSpace(ref.Property)
+ if prop == "" {
+ return secret, nil
+ }
+
+ // For more obvious behavior, we return an error if we are dealing with invalid JSON
+ // this is needed, because the gjson library works fine with value for `key`, for example:
+ //
+ // {"key": "value", another: "value"}
+ //
+ // but it will return "" when accessing to a property `another` (no quotes)
+ if err = json.Unmarshal(secret, &map[string]interface{}{}); err != nil {
+ return nil, fmt.Errorf("expecting the secret %q in JSON format, could not access property %q", ref.Key, ref.Property)
+ }
+
+ result := gjson.Parse(string(secret)).Get(prop)
+ if !result.Exists() {
+ return nil, fmt.Errorf("the requested property %q does not exist in secret %q", prop, ref.Key)
+ }
+
+ return []byte(result.Str), nil
+}
+
+func (c *Client) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+ secret, err := c.accessSecret(ctx, ref.Key, ref.Version)
+ if err != nil {
+ return nil, err
+ }
+
+ secretMap := make(map[string]json.RawMessage)
+ if err = json.Unmarshal(secret, &secretMap); err != nil {
+ return nil, fmt.Errorf("expecting the secret %q in JSON format", ref.Key)
+ }
+
+ out := make(map[string][]byte)
+ for k, v := range secretMap {
+ out[k] = []byte(strings.Trim(string(v), "\""))
+ }
+
+ return out, nil
+}
+
+// GetAllSecrets gets all secrets by the remote reference.
+func (c *Client) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecretFind) (map[string][]byte, error) {
+ if len(ref.Tags) == 0 && ref.Name == nil {
+ return nil, fmt.Errorf("at least one of the following fields must be set: tags, name")
+ }
+
+ var nameFilter string
+ if ref.Name != nil {
+ nameFilter = ref.Name.RegExp
+ }
+
+ var totalSecrets []*smsV1.Secret
+ searchReq := &adapter.ListSecretsRequest{
+ ParentID: c.productInstanceID,
+ Labels: ref.Tags,
+ NameRegex: nameFilter,
+ Offset: 0,
+ }
+ for {
+ secrets, err := c.apiClient.ListSecrets(ctx, searchReq)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list secrets: %s", err)
+ }
+ if len(secrets) == 0 {
+ break
+ }
+
+ totalSecrets = append(totalSecrets, secrets...)
+ searchReq.Offset += int32(len(secrets))
+ }
+
+ out := make(map[string][]byte)
+ for _, s := range totalSecrets {
+ secret, err := c.GetSecretMap(ctx, esv1beta1.ExternalSecretDataRemoteRef{Key: s.GetId()})
+ if err != nil {
+ return nil, err
+ }
+
+ for k, v := range secret {
+ out[k] = v
+ }
+ }
+
+ return utils.ConvertKeys(ref.ConversionStrategy, out)
+}
+
+func (c *Client) accessSecret(ctx context.Context, key, version string) ([]byte, error) {
+ if version == "" {
+ version = "latest"
+ }
+
+ // check if the secret key is UUID
+ // The uuid value means that the provided `key` is a secret identifier
+ // if not, then it is a secret name, and we need to get the secret by
+ // name before accessing the version.
+ if _, err := uuid.Parse(key); err != nil {
+ var secrets []*smsV1.Secret
+ secrets, err = c.apiClient.ListSecrets(ctx, &adapter.ListSecretsRequest{
+ ParentID: c.productInstanceID,
+ NameExact: key,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("list secrets by name '%s': %s", key, err)
+ }
+ if len(secrets) == 0 {
+ return nil, fmt.Errorf("secret with name '%s' not found", key)
+ }
+
+ key = secrets[0].GetId()
+ }
+
+ return c.apiClient.AccessSecretVersion(ctx, key, version)
+}
+
+func (c *Client) PushSecret(context.Context, *corev1.Secret, esv1beta1.PushSecretData) error {
+ return fmt.Errorf("push secret is not supported")
+}
+
+func (c *Client) DeleteSecret(context.Context, esv1beta1.PushSecretRemoteRef) error {
+ return fmt.Errorf("delete secret is not supported")
+}
+
+func (c *Client) SecretExists(context.Context, esv1beta1.PushSecretRemoteRef) (bool, error) {
+ return false, fmt.Errorf("secret exists is not supported")
+}
+
+// Validate validates the client.
+func (c *Client) Validate() (esv1beta1.ValidationResult, error) {
+ return esv1beta1.ValidationResultReady, nil
+}
+
+// Close closes the client.
+func (c *Client) Close(_ context.Context) error { return nil }
diff --git a/pkg/provider/cloudru/secretmanager/client_test.go b/pkg/provider/cloudru/secretmanager/client_test.go
new file mode 100644
index 00000000000..dc8ddae0e8a
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/client_test.go
@@ -0,0 +1,328 @@
+package secretmanager
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ smsV1 "github.com/cloudru-tech/secret-manager-sdk/api/v1"
+ "github.com/google/uuid"
+ tassert "github.com/stretchr/testify/assert"
+
+ esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+ "github.com/external-secrets/external-secrets/pkg/provider/cloudru/secretmanager/fake"
+)
+
+func TestClient_GetSecret(t *testing.T) {
+ tests := []struct {
+ name string
+ ref esv1beta1.ExternalSecretDataRemoteRef
+ setup func(mock *fake.MockSecretProvider)
+ wantPayload []byte
+ wantErr error
+ }{
+ {
+ name: "success",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: uuid.NewString(),
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion([]byte("secret"), nil)
+ },
+ wantPayload: []byte("secret"),
+ wantErr: nil,
+ },
+ {
+ name: "success_named_secret",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "very_secret",
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ // before it should find the secret by the name.
+ mock.MockListSecrets([]*smsV1.Secret{
+ {
+ Id: "50000000-4000-3000-2000-100000000001",
+ Name: "very_secret",
+ },
+ }, nil)
+ mock.MockAccessSecretVersion([]byte("secret"), nil)
+ },
+ wantPayload: []byte("secret"),
+ wantErr: nil,
+ },
+ {
+ name: "success_multikv",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: uuid.NewString(),
+ Version: "1",
+ Property: "another.secret",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion([]byte(`{"some": "value", "another": {"secret": "another_value"}}`), nil)
+ },
+ wantPayload: []byte("another_value"),
+ wantErr: nil,
+ },
+ {
+ name: "error_access_secret",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: uuid.NewString(),
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion(nil, errors.New("secret id is invalid"))
+ },
+ wantPayload: nil,
+ wantErr: errors.New("secret id is invalid"),
+ },
+ {
+ name: "error_access_named_secret",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "very_secret",
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockListSecrets(nil, errors.New("internal server error"))
+ },
+ wantPayload: nil,
+ wantErr: errors.New("list secrets by name 'very_secret': internal server error"),
+ },
+ {
+ name: "error_access_named_secret:not_found",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "very_secret",
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockListSecrets(nil, nil)
+ },
+ wantPayload: nil,
+ wantErr: errors.New("secret with name 'very_secret' not found"),
+ },
+ {
+ name: "error_multikv:invalid_json",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "50000000-4000-3000-2000-100000000001",
+ Version: "1",
+ Property: "some",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion([]byte(`"some": "value"`), nil)
+ },
+ wantPayload: nil,
+ wantErr: errors.New(`expecting the secret "50000000-4000-3000-2000-100000000001" in JSON format, could not access property "some"`),
+ },
+ {
+ name: "error_multikv:not_found",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "50000000-4000-3000-2000-100000000001",
+ Version: "1",
+ Property: "unexpected",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion([]byte(`{"some": "value"}`), nil)
+ },
+ wantPayload: nil,
+ wantErr: errors.New(`the requested property "unexpected" does not exist in secret "50000000-4000-3000-2000-100000000001"`),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mock := &fake.MockSecretProvider{}
+ tt.setup(mock)
+ c := &Client{
+ apiClient: mock,
+ productInstanceID: "123",
+ }
+
+ got, gotErr := c.GetSecret(context.Background(), tt.ref)
+
+ tassert.Equal(t, tt.wantPayload, got)
+ tassert.Equal(t, tt.wantErr, gotErr)
+ })
+ }
+}
+
+func TestClient_GetSecretMap(t *testing.T) {
+ tests := []struct {
+ name string
+ ref esv1beta1.ExternalSecretDataRemoteRef
+ setup func(mock *fake.MockSecretProvider)
+ wantPayload map[string][]byte
+ wantErr error
+ }{
+ {
+ name: "success",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "50000000-4000-3000-2000-100000000001",
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion([]byte(`{"some": "value", "another": "value", "foo": {"bar": "baz"}}`), nil)
+ },
+ wantPayload: map[string][]byte{
+ "some": []byte("value"),
+ "another": []byte("value"),
+ "foo": []byte(`{"bar": "baz"}`),
+ },
+ wantErr: nil,
+ },
+ {
+ name: "error_access_secret",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "50000000-4000-3000-2000-100000000001",
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion(nil, errors.New("secret id is invalid"))
+ },
+ wantPayload: nil,
+ wantErr: errors.New("secret id is invalid"),
+ },
+ {
+ name: "error_not_json",
+ ref: esv1beta1.ExternalSecretDataRemoteRef{
+ Key: "50000000-4000-3000-2000-100000000001",
+ Version: "1",
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockAccessSecretVersion([]byte(`top_secret`), nil)
+ },
+ wantPayload: nil,
+ wantErr: errors.New(`expecting the secret "50000000-4000-3000-2000-100000000001" in JSON format`),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mock := &fake.MockSecretProvider{}
+ tt.setup(mock)
+ c := &Client{
+ apiClient: mock,
+ productInstanceID: "123",
+ }
+
+ got, gotErr := c.GetSecretMap(context.Background(), tt.ref)
+
+ tassert.Equal(t, tt.wantErr, gotErr)
+ tassert.Equal(t, len(tt.wantPayload), len(got))
+ for k, v := range tt.wantPayload {
+ tassert.Equal(t, v, got[k])
+ }
+ })
+ }
+}
+
+func TestClient_GetAllSecrets(t *testing.T) {
+ tests := []struct {
+ name string
+ ref esv1beta1.ExternalSecretFind
+ setup func(mock *fake.MockSecretProvider)
+ wantPayload map[string][]byte
+ wantErr error
+ }{
+ {
+ name: "success",
+ ref: esv1beta1.ExternalSecretFind{
+ Name: &esv1beta1.FindName{RegExp: "label.*"},
+ Tags: map[string]string{
+ "env": "prod",
+ },
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockListSecrets([]*smsV1.Secret{
+ {
+ Id: "50000000-4000-3000-2000-100000000001",
+ Name: "secret1",
+ },
+ {
+ Id: "50000000-4000-3000-2000-100000000002",
+ Name: "secret2",
+ },
+ }, nil)
+
+ mock.MockListSecrets(nil, nil) // mock next call
+
+ mock.MockAccessSecretVersion([]byte(`{"some": "value", "another": "value", "foo": {"bar": "baz"}}`), nil)
+ mock.MockAccessSecretVersion([]byte(`{"second_secret": "prop_value"}`), nil)
+ },
+ wantPayload: map[string][]byte{
+ "some": []byte("value"),
+ "another": []byte("value"),
+ "foo": []byte(`{"bar": "baz"}`),
+ "second_secret": []byte("prop_value"),
+ },
+ wantErr: nil,
+ },
+ {
+ name: "error_no_filters",
+ ref: esv1beta1.ExternalSecretFind{},
+ setup: func(mock *fake.MockSecretProvider) {},
+ wantPayload: nil,
+ wantErr: errors.New("at least one of the following fields must be set: tags, name"),
+ },
+ {
+ name: "error_list_secrets",
+ ref: esv1beta1.ExternalSecretFind{
+ Name: &esv1beta1.FindName{RegExp: "label.*"},
+ Tags: map[string]string{
+ "env": "prod",
+ },
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockListSecrets(nil, errors.New("internal server error"))
+ },
+ wantPayload: nil,
+ wantErr: errors.New("failed to list secrets: internal server error"),
+ },
+ {
+ name: "error_not_json",
+ ref: esv1beta1.ExternalSecretFind{
+ Name: &esv1beta1.FindName{RegExp: "label.*"},
+ Tags: map[string]string{
+ "env": "prod",
+ },
+ },
+ setup: func(mock *fake.MockSecretProvider) {
+ mock.MockListSecrets([]*smsV1.Secret{
+ {
+ Id: "50000000-4000-3000-2000-100000000001",
+ Name: "secret1",
+ },
+ {
+ Id: "50000000-4000-3000-2000-100000000002",
+ Name: "secret2",
+ },
+ }, nil)
+ mock.MockListSecrets(nil, nil) // mock next call
+
+ mock.MockAccessSecretVersion([]byte(`{"some": "value", "another": "value", "foo": {"bar": "baz"}}`), nil)
+ mock.MockAccessSecretVersion([]byte(`top_secret`), nil)
+ },
+ wantPayload: nil,
+ wantErr: errors.New(`expecting the secret "50000000-4000-3000-2000-100000000002" in JSON format`),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mock := &fake.MockSecretProvider{}
+ tt.setup(mock)
+ c := &Client{
+ apiClient: mock,
+ productInstanceID: "123",
+ }
+
+ got, gotErr := c.GetAllSecrets(context.Background(), tt.ref)
+
+ tassert.Equal(t, tt.wantErr, gotErr)
+ tassert.Equal(t, len(tt.wantPayload), len(got))
+ for k, v := range tt.wantPayload {
+ tassert.Equal(t, v, got[k])
+ }
+ })
+ }
+}
diff --git a/pkg/provider/cloudru/secretmanager/endpoints.go b/pkg/provider/cloudru/secretmanager/endpoints.go
new file mode 100644
index 00000000000..e7dafaf240f
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/endpoints.go
@@ -0,0 +1,73 @@
+/*
+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 secretmanager
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+// EndpointsURI is the URI for getting the actual Cloud.ru API endpoints.
+const EndpointsURI = "https://api.cloud.ru/endpoints"
+
+// EndpointsResponse is a response from the Cloud.ru API.
+type EndpointsResponse struct {
+ // Endpoints contains the list of actual API addresses of Cloud.ru products.
+ Endpoints []Endpoint `json:"endpoints"`
+}
+
+// Endpoint is a product API address.
+type Endpoint struct {
+ ID string `json:"id"`
+ Address string `json:"address"`
+}
+
+// GetEndpoints returns the actual Cloud.ru API endpoints.
+func GetEndpoints(url string) (*EndpointsResponse, error) {
+ req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
+ if err != nil {
+ return nil, fmt.Errorf("construct HTTP request for cloud.ru endpoints: %w", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("get cloud.ru endpoints: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("get cloud.ru endpoints: unexpected status code %d", resp.StatusCode)
+ }
+
+ var endpoints EndpointsResponse
+ if err = json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
+ return nil, fmt.Errorf("decode cloud.ru endpoints: %w", err)
+ }
+
+ return &endpoints, nil
+}
+
+// Get returns the API address of the product by its ID.
+// If the product is not found, the function returns nil.
+func (er *EndpointsResponse) Get(id string) *Endpoint {
+ for i := range er.Endpoints {
+ if er.Endpoints[i].ID == id {
+ return &er.Endpoints[i]
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/provider/cloudru/secretmanager/fake/fake.go b/pkg/provider/cloudru/secretmanager/fake/fake.go
new file mode 100644
index 00000000000..15709de019f
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/fake/fake.go
@@ -0,0 +1,45 @@
+package fake
+
+import (
+ "context"
+
+ smsV1 "github.com/cloudru-tech/secret-manager-sdk/api/v1"
+
+ "github.com/external-secrets/external-secrets/pkg/provider/cloudru/secretmanager/adapter"
+)
+
+type MockSecretProvider struct {
+ ListSecretsFns []func() ([]*smsV1.Secret, error)
+ AccessSecretFns []func() ([]byte, error)
+}
+
+func (m *MockSecretProvider) ListSecrets(_ context.Context, _ *adapter.ListSecretsRequest) ([]*smsV1.Secret, error) {
+ fn := m.ListSecretsFns[0]
+ if len(m.ListSecretsFns) > 1 {
+ m.ListSecretsFns = m.ListSecretsFns[1:]
+ } else {
+ m.ListSecretsFns = nil
+ }
+
+ return fn()
+}
+
+func (m *MockSecretProvider) AccessSecretVersion(_ context.Context, _, _ string) ([]byte, error) {
+ fn := m.AccessSecretFns[0]
+ if len(m.AccessSecretFns) > 1 {
+ m.AccessSecretFns = m.AccessSecretFns[1:]
+ } else {
+ m.AccessSecretFns = nil
+ }
+ return fn()
+}
+
+func (m *MockSecretProvider) MockListSecrets(list []*smsV1.Secret, err error) {
+ m.ListSecretsFns = append(m.ListSecretsFns, func() ([]*smsV1.Secret, error) { return list, err })
+}
+
+func (m *MockSecretProvider) MockAccessSecretVersion(data []byte, err error) {
+ m.AccessSecretFns = append(m.AccessSecretFns, func() ([]byte, error) { return data, err })
+}
+
+func (m *MockSecretProvider) Close() error { return nil }
diff --git a/pkg/provider/cloudru/secretmanager/provider.go b/pkg/provider/cloudru/secretmanager/provider.go
new file mode 100644
index 00000000000..2973287a4c3
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/provider.go
@@ -0,0 +1,202 @@
+package secretmanager
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net/url"
+ "sync"
+ "time"
+
+ authV1 "github.com/cloudru-tech/iam-sdk/api/auth/v1"
+ smsV1 "github.com/cloudru-tech/secret-manager-sdk/api/v1"
+ "github.com/google/uuid"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/keepalive"
+ kclient "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/provider/cloudru/secretmanager/adapter"
+ "github.com/external-secrets/external-secrets/pkg/utils"
+)
+
+func init() {
+ esv1beta1.Register(NewProvider(), &esv1beta1.SecretStoreProvider{CloudruSM: &esv1beta1.CloudruSMProvider{}})
+}
+
+var _ esv1beta1.Provider = &Provider{}
+var _ esv1beta1.SecretsClient = &Client{}
+
+// Provider is a secrets provider for Cloud.ru Secret Manager.
+type Provider struct {
+ mu sync.Mutex
+
+ // clients is a map of Cloud.ru Secret Manager clients.
+ // Is used to cache the clients to avoid multiple connections,
+ // and excess token retrieving with same credentials.
+ clients map[string]*adapter.APIClient
+}
+
+// NewProvider creates a new Cloud.ru Secret Manager Provider.
+func NewProvider() *Provider {
+ return &Provider{
+ clients: make(map[string]*adapter.APIClient),
+ }
+}
+
+// NewClient constructs a Cloud.ru Secret Manager Provider.
+func (p *Provider) NewClient(
+ ctx context.Context,
+ store esv1beta1.GenericStore,
+ kube kclient.Client,
+ namespace string,
+) (esv1beta1.SecretsClient, error) {
+ if _, err := p.ValidateStore(store); err != nil {
+ return nil, fmt.Errorf("invalid store: %w", err)
+ }
+
+ csmRef := store.GetSpec().Provider.CloudruSM
+ storeKind := store.GetObjectKind().GroupVersionKind().Kind
+ cr := NewKubeCredentialsResolver(kube, namespace, storeKind, csmRef.Auth.SecretRef)
+
+ client, err := p.getClient(ctx, csmRef, cr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect cloud.ru services: %w", err)
+ }
+
+ return &Client{
+ apiClient: client,
+ productInstanceID: csmRef.ProductInstanceID,
+ }, nil
+}
+
+func (p *Provider) getClient(ctx context.Context, ref *esv1beta1.CloudruSMProvider, cr adapter.CredentialsResolver) (*adapter.APIClient, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ discoveryURL, tokenURL, smURL, err := provideEndpoints(ref)
+ if err != nil {
+ return nil, fmt.Errorf("parse endpoint URLs: %w", err)
+ }
+
+ creds, err := cr.Resolve(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("resolve API credentials: %w", err)
+ }
+
+ connStack := fmt.Sprintf("%s,%s+%s", discoveryURL, creds.KeyID, creds.Secret)
+ client, ok := p.clients[connStack]
+ if ok {
+ return client, nil
+ }
+ iamConn, err := grpc.NewClient(tokenURL,
+ grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS13})),
+ grpc.WithKeepaliveParams(keepalive.ClientParameters{
+ Time: time.Second * 30,
+ Timeout: time.Second * 5,
+ PermitWithoutStream: false,
+ }),
+ grpc.WithUserAgent("external-secrets"),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("initialize cloud.ru IAM gRPC client: initiate connection: %w", err)
+ }
+
+ smsConn, err := grpc.NewClient(smURL,
+ grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS13})),
+ grpc.WithKeepaliveParams(keepalive.ClientParameters{
+ Time: time.Second * 30,
+ Timeout: time.Second * 5,
+ PermitWithoutStream: false,
+ }),
+ grpc.WithUserAgent("external-secrets"),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("initialize cloud.ru Secret Manager gRPC client: initiate connection: %w", err)
+ }
+
+ iamClient := authV1.NewAuthServiceClient(iamConn)
+ smsClient := smsV1.NewSecretManagerServiceClient(smsConn)
+ client = adapter.NewAPIClient(cr, iamClient, smsClient)
+
+ p.clients[connStack] = client
+ return client, nil
+}
+
+// ValidateStore validates the store specification.
+func (p *Provider) ValidateStore(store esv1beta1.GenericStore) (admission.Warnings, error) {
+ if store == nil {
+ return nil, errors.New("store is not provided")
+ }
+ spec := store.GetSpec()
+ if spec == nil || spec.Provider == nil || spec.Provider.CloudruSM == nil {
+ return nil, errors.New("csm spec is not provided")
+ }
+
+ csmProvider := spec.Provider.CloudruSM
+ switch {
+ case csmProvider.Auth.SecretRef == nil:
+ return nil, errors.New("invalid spec: auth.secretRef is required")
+ case csmProvider.ProductInstanceID == "":
+ return nil, errors.New("invalid spec: productInstanceID is required")
+ }
+ if _, err := uuid.Parse(csmProvider.ProductInstanceID); err != nil {
+ return nil, fmt.Errorf("invalid spec: productInstanceID is invalid UUID: %w", err)
+ }
+
+ ref := csmProvider.Auth.SecretRef
+ err := utils.ValidateReferentSecretSelector(store, ref.AccessKeyID)
+ if err != nil {
+ return nil, fmt.Errorf("invalid spec: auth.secretRef.accessKeyID: %w", err)
+ }
+
+ err = utils.ValidateReferentSecretSelector(store, ref.AccessKeySecret)
+ if err != nil {
+ return nil, fmt.Errorf("invalid spec: auth.secretRef.accessKeySecret: %w", err)
+ }
+
+ return nil, nil
+}
+
+// Capabilities returns the provider Capabilities (ReadOnly).
+func (p *Provider) Capabilities() esv1beta1.SecretStoreCapabilities {
+ return esv1beta1.SecretStoreReadOnly
+}
+
+func provideEndpoints(ref *esv1beta1.CloudruSMProvider) (discoveryURL, tokenURL, smURL string, err error) {
+ endpointsURL := EndpointsURI
+ if ref.DiscoveryURL != "" {
+ var u *url.URL
+ u, err = url.Parse(ref.DiscoveryURL)
+ if err != nil {
+ return
+ }
+ if u.Scheme != "https" && u.Scheme != "http" {
+ return "", "", "", errors.New("invalid scheme in discovery URL")
+ }
+
+ endpointsURL = ref.DiscoveryURL
+ }
+
+ // using the discovery URL to get the endpoints.
+ var endpoints *EndpointsResponse
+ endpoints, err = GetEndpoints(endpointsURL)
+ if err != nil {
+ return
+ }
+
+ smEndpoint := endpoints.Get("secret-manager")
+ if smEndpoint == nil {
+ return "", "", "", errors.New("secret-manager API is not available")
+ }
+
+ iamEndpoint := endpoints.Get("iam")
+ if iamEndpoint == nil {
+ return "", "", "", errors.New("iam API is not available")
+ }
+
+ return discoveryURL, iamEndpoint.Address, smEndpoint.Address, nil
+}
diff --git a/pkg/provider/cloudru/secretmanager/resolver.go b/pkg/provider/cloudru/secretmanager/resolver.go
new file mode 100644
index 00000000000..ee3fabb9f71
--- /dev/null
+++ b/pkg/provider/cloudru/secretmanager/resolver.go
@@ -0,0 +1,51 @@
+package secretmanager
+
+import (
+ "context"
+ "fmt"
+
+ kclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
+ "github.com/external-secrets/external-secrets/pkg/provider/cloudru/secretmanager/adapter"
+ "github.com/external-secrets/external-secrets/pkg/utils/resolvers"
+)
+
+// KubeCredentialsResolver resolves the credentials from the Kubernetes secret.
+type KubeCredentialsResolver struct {
+ ref *v1beta1.CSMAuthSecretRef
+ kube kclient.Client
+
+ namespace string
+ storeKind string
+}
+
+// NewKubeCredentialsResolver creates a new KubeCredentialsResolver.
+func NewKubeCredentialsResolver(kube kclient.Client, namespace, storeKind string, ref *v1beta1.CSMAuthSecretRef) *KubeCredentialsResolver {
+ return &KubeCredentialsResolver{
+ ref: ref,
+ kube: kube,
+ namespace: namespace,
+ storeKind: storeKind,
+ }
+}
+
+// Resolve resolves the credentials from the Kubernetes secret.
+func (kcr *KubeCredentialsResolver) Resolve(ctx context.Context) (*adapter.Credentials, error) {
+ keyID, err := resolvers.SecretKeyRef(ctx, kcr.kube, kcr.storeKind, kcr.namespace, &kcr.ref.AccessKeyID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve accessKeyID: %w", err)
+ }
+
+ secret, err := resolvers.SecretKeyRef(ctx, kcr.kube, kcr.storeKind, kcr.namespace, &kcr.ref.AccessKeySecret)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve accessKeySecret")
+ }
+
+ creds, err := adapter.NewCredentials(keyID, secret)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get auth credentials: %w", err)
+ }
+
+ return creds, nil
+}
diff --git a/pkg/provider/register/register.go b/pkg/provider/register/register.go
index 80e0e4019df..9a3c8f57613 100644
--- a/pkg/provider/register/register.go
+++ b/pkg/provider/register/register.go
@@ -22,6 +22,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/chef"
+ _ "github.com/external-secrets/external-secrets/pkg/provider/cloudru/secretmanager"
_ "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"
From e333fc9ab21b99da111476a02ea56ff71e49dbf5 Mon Sep 17 00:00:00 2001
From: Dmitry Ivanov
Date: Wed, 26 Jun 2024 15:42:04 +0300
Subject: [PATCH 2/2] feat: add cloudru documentation
---
docs/api/spec.md | 145 +++++++++++++
docs/introduction/stability-support.md | 12 +-
docs/provider/cloudru.md | 192 ++++++++++++++++++
hack/api-docs/mkdocs.yml | 1 +
.../secretmanager/adapter/csm_client.go | 4 +-
pkg/provider/cloudru/secretmanager/client.go | 4 +-
.../cloudru/secretmanager/client_test.go | 14 ++
.../cloudru/secretmanager/fake/fake.go | 14 ++
.../cloudru/secretmanager/provider.go | 22 +-
.../cloudru/secretmanager/resolver.go | 14 ++
10 files changed, 409 insertions(+), 13 deletions(-)
create mode 100644 docs/provider/cloudru.md
diff --git a/docs/api/spec.md b/docs/api/spec.md
index 1efaa7d7fee..602d9c0c33d 100644
--- a/docs/api/spec.md
+++ b/docs/api/spec.md
@@ -1080,6 +1080,83 @@ Can only be defined when used in a ClusterSecretStore.
|
+CSMAuth
+
+
+(Appears on:
+CloudruSMProvider)
+
+
+
CSMAuth contains a secretRef for credentials.
+
+
+CSMAuthSecretRef
+
+
+(Appears on:
+CSMAuth)
+
+
+
CSMAuthSecretRef holds secret references for Cloud.ru credentials.
+
+
CertAuth
@@ -1239,6 +1316,60 @@ string
+
CloudruSMProvider
+
+
+(Appears on:
+SecretStoreProvider)
+
+
+
CloudruSMProvider configures a store to sync secrets using the Cloud.ru Secret Manager provider.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+discoveryURL
+
+string
+
+ |
+
+(Optional)
+ DiscoveryURL is used to connect to the Cloud.ru product APIs.
+ |
+
+
+
+auth
+
+
+CSMAuth
+
+
+ |
+
+ |
+
+
+
+productInstanceID
+
+string
+
+ |
+
+ ProductInstanceID is the service, which the secrets are stored in.
+ |
+
+
+
ClusterExternalSecret
@@ -6030,6 +6161,20 @@ PassboltProvider
(Optional)
+
+
+cloudrusm
+
+
+CloudruSMProvider
+
+
+ |
+
+(Optional)
+ CloudruSM configures this store to sync secrets using the Cloud.ru Secret Manager provider
+ |
+
SecretStoreRef
diff --git a/docs/introduction/stability-support.md b/docs/introduction/stability-support.md
index cabcd87100d..28f61dcd405 100644
--- a/docs/introduction/stability-support.md
+++ b/docs/introduction/stability-support.md
@@ -32,7 +32,7 @@ We want to cover the following cases:
The following table describes the stability level of each provider and who's responsible.
| Provider | Stability | Maintainer |
-|------------------------------------------------------------------------------------------------------------| :-------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
+|------------------------------------------------------------------------------------------------------------| :-------: |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
| [AWS Secrets Manager](https://external-secrets.io/latest/provider/aws-secrets-manager/) | stable | [external-secrets](https://github.com/external-secrets) |
| [AWS Parameter Store](https://external-secrets.io/latest/provider/aws-parameter-store/) | stable | [external-secrets](https://github.com/external-secrets) |
| [Hashicorp Vault](https://external-secrets.io/latest/provider/hashicorp-vault/) | stable | [external-secrets](https://github.com/external-secrets) |
@@ -51,17 +51,18 @@ The following table describes the stability level of each provider and who's res
| [Doppler SecretOps Platform](https://external-secrets.io/latest/provider/doppler) | alpha | [@ryan-blunden](https://github.com/ryan-blunden/) [@nmanoogian](https://github.com/nmanoogian/) |
| [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) | stable | [@davidh-cyberark](https://github.com/davidh-cyberark/) [@szh](https://github.com/szh) |
+| [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/) |
-| [Pulumi ESC](https://external-secrets.io/latest/provider/pulumi) | alpha | [@dirien](https://github.com/dirien) |
-| [Passbolt](https://external-secrets.io/latest/provider/passbolt) | alpha | |
+| [Pulumi ESC](https://external-secrets.io/latest/provider/pulumi) | alpha | [@dirien](https://github.com/dirien) |
+| [Passbolt](https://external-secrets.io/latest/provider/passbolt) | alpha | |
+| [Cloud.ru](https://external-secrets.io/latest/provider/cloudru) | alpha | [@default23](https://github.com/default23) |
## Provider Feature Support
The following table show the support for features across different providers.
| Provider | find by name | find by tags | metadataPolicy Fetch | referent authentication | store validation | push secret | DeletionPolicy Merge/Delete |
-| ------------------------- | :----------: | :----------: | :------------------: | :---------------------: | :--------------: | :---------: | :-------------------------: |
+|---------------------------| :----------: |:------------:| :------------------: |:-----------------------:| :--------------: | :---------: | :-------------------------: |
| AWS Secrets Manager | x | x | x | x | x | x | x |
| AWS Parameter Store | x | x | x | x | x | x | x |
| Hashicorp Vault | x | x | x | x | x | x | x |
@@ -84,6 +85,7 @@ The following table show the support for features across different providers.
| Delinea | x | | | | x | | |
| Pulumi ESC | x | | | | x | | |
| Passbolt | x | | | | x | | |
+| Cloud.ru | x | x | | x | x | | |
## Support Policy
diff --git a/docs/provider/cloudru.md b/docs/provider/cloudru.md
new file mode 100644
index 00000000000..570a6d94d0a
--- /dev/null
+++ b/docs/provider/cloudru.md
@@ -0,0 +1,192 @@
+External Secrets Operator integrates with [Cloud.ru](https://cloud.ru) for secret management.
+
+Cloud.ru Secret Manager works in conjunction with the Key Manager cryptographic key management system to ensure secure
+encryption of secrets.
+
+### Authentication
+
+* Before you can use the Cloud.ru Secret Manager, you need to create a service account in
+ the [Cloud.ru Console](https://console.cloud.ru).
+* Create a [Service Account](https://cloud.ru/ru/docs/console_api/ug/topics/guides__service_accounts_create.html)
+ and [Access Key](https://cloud.ru/ru/docs/console_api/ug/topics/guides__service_accounts_key.html) for it.
+
+**NOTE:** To interact with the SecretManager API, you need to use the access token. You can get it by running the
+following command, using the Access Key, created above:
+
+```shell
+curl -i --data-urlencode 'grant_type=access_key' \
+ --data-urlencode "client_id=$KEY_ID" \
+ --data-urlencode "client_secret=$SECRET" \
+ https://id.cloud.ru/auth/system/openid/token
+```
+
+### Creating Cloud.ru secret
+
+To make External Secrets Operator sync a k8s secret with a Cloud.ru secret:
+
+* Navigate to the [Cloud.ru Console](https://console.cloud.ru/).
+* Click the menu at upper-left corner, scroll down to the `Management` section and click on `Secret Manager`.
+* Click on `Create secret`.
+* Fill in the secret name and secret value.
+* Click on `Create`.
+
+Also, you can use [SecretManager API](https://cloud.ru/ru/docs/scsm/ug/topics/guides__add-secret.html) to create the
+secret:
+
+```shell
+curl --location 'https://secretmanager.api.cloud.ru/v1/secrets' \
+--header 'Content-Type: application/json' \
+--header 'Authorization: Bearer ${ACCESS_TOKEN}' \
+--data '{
+ "description": "your secret description",
+ "labels": {
+ "env": "production"
+ },
+ "name": "my_first_secret",
+ "parent_id": "50000000-4000-3000-2000-100000000001",
+ "payload": {
+ "data": {
+ "value": "aGksIHRoZXJlJ3Mgbm90aGluZyBpbnRlcmVzdGluZyBoZXJlCg=="
+ }
+ }
+}'
+```
+
+* `ACCESS_TOKEN` is the access token for the Cloud.ru API. See **Authentication** section
+* `parent_id` parent service instance identifier: ServiceInstanceID. To get the ID value, in your personal account on
+ the top left panel, click the Button with nine dots, select **Management** → **Secret Manager** and copy the value
+ from the Service Instance ID field.
+* `name` is the name of the secret.
+* `description` is the description of the secret.
+* `labels` are the labels(tags) for the secret. Is used in the search.
+* `payload.data.value` is the base64-encoded secret value.
+
+**NOTE:** To create the Multi KeyValue secret in Cloud.ru, you can use the following format (json):
+
+```json
+{
+ "key1": "value1",
+ "key2": "value2"
+}
+```
+
+### Creating ExternalSecret
+
+* Create the k8s Secret, it will be used for authentication in SecretStore:
+ ```yaml
+ apiVersion: v1
+ kind: Secret
+ metadata:
+ name: csm-secret
+ labels:
+ type: csm
+ type: Opaque
+ stringData:
+ key_id: '000000000000000000001'
+ key_secret: '000000000000000000002'
+ ```
+ * `key_id` is the AccessKey key_id.
+ * `key_secret` is the AccessKey key_secret
+* Create a [SecretStore](../api/secretstore.md) pointing to `csm-secret` k8s Secret:
+ ```yaml
+ apiVersion: external-secrets.io/v1beta1
+ kind: SecretStore
+ metadata:
+ name: csm
+ spec:
+ provider:
+ cloudrusm:
+ auth:
+ secretRef:
+ accessKeyIDSecretRef:
+ name: csm-secret
+ key: key_id
+ accessKeySecretSecretRef:
+ name: csm-secret
+ key: key_secret
+ productInstanceID: 50000000-4000-3000-2000-100000000001
+ ```
+ * `accessKeyIDSecretRef` is the reference to the k8s Secret with the AccessKey.
+ * `productInstanceID` is the parent service instance identifier: ServiceInstanceID. To get the ID value, in your
+ personal account on the top left panel, click the Button with nine dots, select **Management** → **Secret Manager**
+ and copy the value from the Service Instance ID field.
+#### Create an [ExternalSecret](../api/externalsecret.md) pointing to SecretStore.
+ * Classic, non-json:
+ ```yaml
+ apiVersion: external-secrets.io/v1beta1
+ kind: ExternalSecret
+ metadata:
+ name: csm-ext-secret
+ spec:
+ refreshInterval: 10s
+ secretStoreRef:
+ name: csm
+ kind: SecretStore
+ target:
+ name: my-awesome-secret
+ creationPolicy: Owner
+ data:
+ - secretKey: target_key
+ remoteRef:
+ key: my_first_secret # or you can use the secret.id (e.g. 50000000-4000-3000-2000-100000000001)
+ ```
+ * From Multi KeyValue, value MUST be in **json format**:
+ ```yaml
+ apiVersion: external-secrets.io/v1beta1
+ kind: ExternalSecret
+ metadata:
+ name: csm-ext-secret
+ spec:
+ refreshInterval: 10s
+ secretStoreRef:
+ name: csm
+ kind: SecretStore
+ target:
+ name: my-awesome-secret
+ creationPolicy: Owner
+ data:
+ - secretKey: target_key
+ remoteRef:
+ key: my_first_secret # or you can use the secret.id (e.g. 50000000-4000-3000-2000-100000000001)
+ property: cloudru.secret.key # is the JSON path for the key in the secret value.
+ ```
+
+ * With all fields, value MUST be in **json format**:
+ ```yaml
+ apiVersion: external-secrets.io/v1beta1
+ kind: ExternalSecret
+ metadata:
+ name: csm-ext-secret
+ spec:
+ refreshInterval: 10s
+ secretStoreRef:
+ name: csm
+ kind: SecretStore
+ target:
+ name: my-awesome-secret
+ creationPolicy: Owner
+ dataFrom:
+ - extract:
+ key: my_first_secret # or you can use the secret.id (e.g. 50000000-4000-3000-2000-100000000001)
+ ```
+ * Search the secrets by the Name or Labels (tags):
+ ```yaml
+ apiVersion: external-secrets.io/v1beta1
+ kind: ExternalSecret
+ metadata:
+ name: csm-ext-secret
+ spec:
+ refreshInterval: 10s
+ secretStoreRef:
+ name: csm
+ kind: SecretStore
+ target:
+ name: my-awesome-secret
+ creationPolicy: Owner
+ dataFrom:
+ - find:
+ tags:
+ env: production
+ name:
+ regexp: "my.*secret"
+ ```
diff --git a/hack/api-docs/mkdocs.yml b/hack/api-docs/mkdocs.yml
index 294f93ecf1b..c3de1937257 100644
--- a/hack/api-docs/mkdocs.yml
+++ b/hack/api-docs/mkdocs.yml
@@ -93,6 +93,7 @@ nav:
- AWS Parameter Store: provider/aws-parameter-store.md
- Azure Key Vault: provider/azure-key-vault.md
- Chef: provider/chef.md
+ - Cloud.ru Secret Manager: provider/cloudru.md
- CyberArk Conjur: provider/conjur.md
- Google Cloud Secret Manager: provider/google-secrets-manager.md
- HashiCorp Vault: provider/hashicorp-vault.md
diff --git a/pkg/provider/cloudru/secretmanager/adapter/csm_client.go b/pkg/provider/cloudru/secretmanager/adapter/csm_client.go
index a6182686205..f102aae7360 100644
--- a/pkg/provider/cloudru/secretmanager/adapter/csm_client.go
+++ b/pkg/provider/cloudru/secretmanager/adapter/csm_client.go
@@ -38,7 +38,7 @@ type CredentialsResolver interface {
Resolve(ctx context.Context) (*Credentials, error)
}
-// APIClient - Cloudru Secret Manager Service Client
+// APIClient - Cloudru Secret Manager Service Client.
type APIClient struct {
cr CredentialsResolver
@@ -137,7 +137,7 @@ func (c *APIClient) AccessSecretVersion(ctx context.Context, id, version string)
return nil, fmt.Errorf("secret '%s %s' not found", id, version)
}
- return nil, fmt.Errorf("failed to get the secret by id '%s v%s': %s", id, version, err)
+ return nil, fmt.Errorf("failed to get the secret by id '%s v%s': %w", id, version, err)
}
return secret.GetData().GetValue(), nil
diff --git a/pkg/provider/cloudru/secretmanager/client.go b/pkg/provider/cloudru/secretmanager/client.go
index fdee7124d7e..3700d75e5d5 100644
--- a/pkg/provider/cloudru/secretmanager/client.go
+++ b/pkg/provider/cloudru/secretmanager/client.go
@@ -115,7 +115,7 @@ func (c *Client) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecret
for {
secrets, err := c.apiClient.ListSecrets(ctx, searchReq)
if err != nil {
- return nil, fmt.Errorf("failed to list secrets: %s", err)
+ return nil, fmt.Errorf("failed to list secrets: %w", err)
}
if len(secrets) == 0 {
break
@@ -156,7 +156,7 @@ func (c *Client) accessSecret(ctx context.Context, key, version string) ([]byte,
NameExact: key,
})
if err != nil {
- return nil, fmt.Errorf("list secrets by name '%s': %s", key, err)
+ return nil, fmt.Errorf("list secrets by name '%s': %w", key, err)
}
if len(secrets) == 0 {
return nil, fmt.Errorf("secret with name '%s' not found", key)
diff --git a/pkg/provider/cloudru/secretmanager/client_test.go b/pkg/provider/cloudru/secretmanager/client_test.go
index dc8ddae0e8a..3dd2d21d61d 100644
--- a/pkg/provider/cloudru/secretmanager/client_test.go
+++ b/pkg/provider/cloudru/secretmanager/client_test.go
@@ -1,3 +1,17 @@
+/*
+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 secretmanager
import (
diff --git a/pkg/provider/cloudru/secretmanager/fake/fake.go b/pkg/provider/cloudru/secretmanager/fake/fake.go
index 15709de019f..1427e1fbd2c 100644
--- a/pkg/provider/cloudru/secretmanager/fake/fake.go
+++ b/pkg/provider/cloudru/secretmanager/fake/fake.go
@@ -1,3 +1,17 @@
+/*
+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 fake
import (
diff --git a/pkg/provider/cloudru/secretmanager/provider.go b/pkg/provider/cloudru/secretmanager/provider.go
index 2973287a4c3..d8d714a80a2 100644
--- a/pkg/provider/cloudru/secretmanager/provider.go
+++ b/pkg/provider/cloudru/secretmanager/provider.go
@@ -1,3 +1,17 @@
+/*
+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 secretmanager
import (
@@ -172,10 +186,10 @@ func provideEndpoints(ref *esv1beta1.CloudruSMProvider) (discoveryURL, tokenURL,
var u *url.URL
u, err = url.Parse(ref.DiscoveryURL)
if err != nil {
- return
+ return "", "", "", fmt.Errorf("invalid discovery URL: %w", err)
}
if u.Scheme != "https" && u.Scheme != "http" {
- return "", "", "", errors.New("invalid scheme in discovery URL")
+ return "", "", "", errors.New("invalid scheme in discovery URL, expecting the http or https")
}
endpointsURL = ref.DiscoveryURL
@@ -185,7 +199,7 @@ func provideEndpoints(ref *esv1beta1.CloudruSMProvider) (discoveryURL, tokenURL,
var endpoints *EndpointsResponse
endpoints, err = GetEndpoints(endpointsURL)
if err != nil {
- return
+ return "", "", "", fmt.Errorf("failed to get the cloud.ru endpoints: %w", err)
}
smEndpoint := endpoints.Get("secret-manager")
@@ -198,5 +212,5 @@ func provideEndpoints(ref *esv1beta1.CloudruSMProvider) (discoveryURL, tokenURL,
return "", "", "", errors.New("iam API is not available")
}
- return discoveryURL, iamEndpoint.Address, smEndpoint.Address, nil
+ return endpointsURL, iamEndpoint.Address, smEndpoint.Address, nil
}
diff --git a/pkg/provider/cloudru/secretmanager/resolver.go b/pkg/provider/cloudru/secretmanager/resolver.go
index ee3fabb9f71..f440804fca5 100644
--- a/pkg/provider/cloudru/secretmanager/resolver.go
+++ b/pkg/provider/cloudru/secretmanager/resolver.go
@@ -1,3 +1,17 @@
+/*
+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 secretmanager
import (