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

+

+ + + + + + + + + + + + + +
FieldDescription
+secretRef
+ + +CSMAuthSecretRef + + +
+(Optional) +
+

CSMAuthSecretRef +

+

+(Appears on: +CSMAuth) +

+

+

CSMAuthSecretRef holds secret references for Cloud.ru credentials.

+

+ + + + + + + + + + + + + + + + + +
FieldDescription
+accessKeyIDSecretRef
+ + +External Secrets meta/v1.SecretKeySelector + + +
+

The AccessKeyID is used for authentication

+
+accessKeySecretSecretRef
+ + +External Secrets meta/v1.SecretKeySelector + + +
+

The AccessKeySecret is used for authentication

+

CertAuth

@@ -1239,6 +1316,60 @@ string +

CloudruSMProvider +

+

+(Appears on: +SecretStoreProvider) +

+

+

CloudruSMProvider configures a store to sync secrets using the Cloud.ru Secret Manager provider.

+

+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+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/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/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 new file mode 100644 index 00000000000..f102aae7360 --- /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': %w", 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..3700d75e5d5 --- /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: %w", 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': %w", 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..3dd2d21d61d --- /dev/null +++ b/pkg/provider/cloudru/secretmanager/client_test.go @@ -0,0 +1,342 @@ +/* +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" + "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..1427e1fbd2c --- /dev/null +++ b/pkg/provider/cloudru/secretmanager/fake/fake.go @@ -0,0 +1,59 @@ +/* +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 ( + "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..d8d714a80a2 --- /dev/null +++ b/pkg/provider/cloudru/secretmanager/provider.go @@ -0,0 +1,216 @@ +/* +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" + "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 "", "", "", fmt.Errorf("invalid discovery URL: %w", err) + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", "", "", errors.New("invalid scheme in discovery URL, expecting the http or https") + } + + endpointsURL = ref.DiscoveryURL + } + + // using the discovery URL to get the endpoints. + var endpoints *EndpointsResponse + endpoints, err = GetEndpoints(endpointsURL) + if err != nil { + return "", "", "", fmt.Errorf("failed to get the cloud.ru endpoints: %w", err) + } + + 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 endpointsURL, 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..f440804fca5 --- /dev/null +++ b/pkg/provider/cloudru/secretmanager/resolver.go @@ -0,0 +1,65 @@ +/* +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" + "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"