diff --git a/apis/externalsecrets/v1beta1/secretsstore_infisical_types.go b/apis/externalsecrets/v1beta1/secretsstore_infisical_types.go new file mode 100644 index 000000000..c1eea0a4d --- /dev/null +++ b/apis/externalsecrets/v1beta1/secretsstore_infisical_types.go @@ -0,0 +1,53 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import ( + esmeta "github.com/external-secrets/external-secrets/apis/meta/v1" +) + +type UniversalAuthCredentials struct { + // +kubebuilder:validation:Required + ClientID esmeta.SecretKeySelector `json:"clientId"` + // +kubebuilder:validation:Required + ClientSecret esmeta.SecretKeySelector `json:"clientSecret"` +} + +type InfisicalAuth struct { + // +optional + UniversalAuthCredentials *UniversalAuthCredentials `json:"universalAuthCredentials,omitempty"` +} + +type MachineIdentityScopeInWorkspace struct { + // +kubebuilder:default="/" + // +optional + SecretsPath string `json:"secretsPath,omitempty"` + // +kubebuilder:validation:Required + EnvironmentSlug string `json:"environmentSlug"` + // +kubebuilder:validation:Required + ProjectSlug string `json:"projectSlug"` +} + +// InfisicalProvider configures a store to sync secrets using the Infisical provider. +type InfisicalProvider struct { + // Auth configures how the Operator authenticates with the Infisical API + // +kubebuilder:validation:Required + Auth InfisicalAuth `json:"auth"` + // +kubebuilder:validation:Required + SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` + // +kubebuilder:default="https://app.infisical.com/api" + // +optional + HostAPI string `json:"hostAPI,omitempty"` +} diff --git a/apis/externalsecrets/v1beta1/secretstore_types.go b/apis/externalsecrets/v1beta1/secretstore_types.go index b05d32e44..c168f7557 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"` + + // Infisical configures this store to sync secrets using the Infisical provider + // +optional + Infisical *InfisicalProvider `json:"infisical,omitempty"` } type CAProviderType string diff --git a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go index db570a763..a1f5b6688 100644 --- a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go +++ b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go @@ -1669,6 +1669,43 @@ func (in *IBMProvider) DeepCopy() *IBMProvider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InfisicalAuth) DeepCopyInto(out *InfisicalAuth) { + *out = *in + if in.UniversalAuthCredentials != nil { + in, out := &in.UniversalAuthCredentials, &out.UniversalAuthCredentials + *out = new(UniversalAuthCredentials) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalAuth. +func (in *InfisicalAuth) DeepCopy() *InfisicalAuth { + if in == nil { + return nil + } + out := new(InfisicalAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InfisicalProvider) DeepCopyInto(out *InfisicalProvider) { + *out = *in + in.Auth.DeepCopyInto(&out.Auth) + out.SecretsScope = in.SecretsScope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InfisicalProvider. +func (in *InfisicalProvider) DeepCopy() *InfisicalProvider { + if in == nil { + return nil + } + out := new(InfisicalProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KeeperSecurityProvider) DeepCopyInto(out *KeeperSecurityProvider) { *out = *in @@ -1757,6 +1794,21 @@ func (in *KubernetesServer) DeepCopy() *KubernetesServer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MachineIdentityScopeInWorkspace) DeepCopyInto(out *MachineIdentityScopeInWorkspace) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineIdentityScopeInWorkspace. +func (in *MachineIdentityScopeInWorkspace) DeepCopy() *MachineIdentityScopeInWorkspace { + if in == nil { + return nil + } + out := new(MachineIdentityScopeInWorkspace) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NoSecretError) DeepCopyInto(out *NoSecretError) { *out = *in @@ -2305,6 +2357,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) { *out = new(PassboltProvider) (*in).DeepCopyInto(*out) } + if in.Infisical != nil { + in, out := &in.Infisical, &out.Infisical + *out = new(InfisicalProvider) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretStoreProvider. @@ -2616,6 +2673,23 @@ func (in *TokenAuth) DeepCopy() *TokenAuth { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UniversalAuthCredentials) DeepCopyInto(out *UniversalAuthCredentials) { + *out = *in + in.ClientID.DeepCopyInto(&out.ClientID) + in.ClientSecret.DeepCopyInto(&out.ClientSecret) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UniversalAuthCredentials. +func (in *UniversalAuthCredentials) DeepCopy() *UniversalAuthCredentials { + if in == nil { + return nil + } + out := new(UniversalAuthCredentials) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VaultAppRole) DeepCopyInto(out *VaultAppRole) { *out = *in diff --git a/config/crds/bases/external-secrets.io_clustersecretstores.yaml b/config/crds/bases/external-secrets.io_clustersecretstores.yaml index 3771ea0b6..2b6ccc9a6 100644 --- a/config/crds/bases/external-secrets.io_clustersecretstores.yaml +++ b/config/crds/bases/external-secrets.io_clustersecretstores.yaml @@ -2883,6 +2883,81 @@ spec: required: - auth type: object + infisical: + description: Infisical configures this store to sync secrets using + the Infisical provider + properties: + auth: + description: Auth configures how the Operator authenticates + with the Infisical API + properties: + universalAuthCredentials: + properties: + clientId: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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 + clientSecret: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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: + - clientId + - clientSecret + type: object + type: object + hostAPI: + default: https://app.infisical.com/api + type: string + secretsScope: + properties: + environmentSlug: + type: string + projectSlug: + type: string + secretsPath: + default: / + type: string + required: + - environmentSlug + - projectSlug + type: object + required: + - auth + - secretsScope + type: object keepersecurity: description: KeeperSecurity configures this store to sync secrets using the KeeperSecurity provider diff --git a/config/crds/bases/external-secrets.io_secretstores.yaml b/config/crds/bases/external-secrets.io_secretstores.yaml index 6bffcf681..d650808b8 100644 --- a/config/crds/bases/external-secrets.io_secretstores.yaml +++ b/config/crds/bases/external-secrets.io_secretstores.yaml @@ -2883,6 +2883,81 @@ spec: required: - auth type: object + infisical: + description: Infisical configures this store to sync secrets using + the Infisical provider + properties: + auth: + description: Auth configures how the Operator authenticates + with the Infisical API + properties: + universalAuthCredentials: + properties: + clientId: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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 + clientSecret: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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: + - clientId + - clientSecret + type: object + type: object + hostAPI: + default: https://app.infisical.com/api + type: string + secretsScope: + properties: + environmentSlug: + type: string + projectSlug: + type: string + secretsPath: + default: / + type: string + required: + - environmentSlug + - projectSlug + type: object + required: + - auth + - secretsScope + type: object keepersecurity: description: KeeperSecurity configures this store to sync secrets using the KeeperSecurity provider diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml index 28f2d2b8a..54e5bfdf3 100644 --- a/deploy/crds/bundle.yaml +++ b/deploy/crds/bundle.yaml @@ -3337,6 +3337,77 @@ spec: required: - auth type: object + infisical: + description: Infisical configures this store to sync secrets using the Infisical provider + properties: + auth: + description: Auth configures how the Operator authenticates with the Infisical API + properties: + universalAuthCredentials: + properties: + clientId: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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 + clientSecret: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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: + - clientId + - clientSecret + type: object + type: object + hostAPI: + default: https://app.infisical.com/api + type: string + secretsScope: + properties: + environmentSlug: + type: string + projectSlug: + type: string + secretsPath: + default: / + type: string + required: + - environmentSlug + - projectSlug + type: object + required: + - auth + - secretsScope + type: object keepersecurity: description: KeeperSecurity configures this store to sync secrets using the KeeperSecurity provider properties: @@ -8712,6 +8783,77 @@ spec: required: - auth type: object + infisical: + description: Infisical configures this store to sync secrets using the Infisical provider + properties: + auth: + description: Auth configures how the Operator authenticates with the Infisical API + properties: + universalAuthCredentials: + properties: + clientId: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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 + clientSecret: + description: |- + A reference to a specific 'key' within a Secret resource, + In some instances, `key` is a required field. + 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: + - clientId + - clientSecret + type: object + type: object + hostAPI: + default: https://app.infisical.com/api + type: string + secretsScope: + properties: + environmentSlug: + type: string + projectSlug: + type: string + secretsPath: + default: / + type: string + required: + - environmentSlug + - projectSlug + type: object + required: + - auth + - secretsScope + type: object keepersecurity: description: KeeperSecurity configures this store to sync secrets using the KeeperSecurity provider properties: diff --git a/docs/api/spec.md b/docs/api/spec.md index 6d521a384..efd79408a 100644 --- a/docs/api/spec.md +++ b/docs/api/spec.md @@ -4372,6 +4372,92 @@ string +

InfisicalAuth +

+

+(Appears on: +InfisicalProvider) +

+

+

+ + + + + + + + + + + + + +
FieldDescription
+universalAuthCredentials
+ + +UniversalAuthCredentials + + +
+(Optional) +
+

InfisicalProvider +

+

+(Appears on: +SecretStoreProvider) +

+

+

InfisicalProvider configures a store to sync secrets using the Infisical provider.

+

+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+auth
+ + +InfisicalAuth + + +
+

Auth configures how the Operator authenticates with the Infisical API

+
+secretsScope
+ + +MachineIdentityScopeInWorkspace + + +
+
+hostAPI
+ +string + +
+(Optional) +

KeeperSecurityProvider

@@ -4586,6 +4672,55 @@ CAProvider +

MachineIdentityScopeInWorkspace +

+

+(Appears on: +InfisicalProvider) +

+

+

+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+secretsPath
+ +string + +
+(Optional) +
+environmentSlug
+ +string + +
+
+projectSlug
+ +string + +
+

NoSecretError

@@ -6055,6 +6190,20 @@ PassboltProvider (Optional) + + +infisical
+ + +InfisicalProvider + + + + +(Optional) +

Infisical configures this store to sync secrets using the Infisical provider

+ +

SecretStoreRef @@ -6932,6 +7081,48 @@ External Secrets meta/v1.SecretKeySelector +

UniversalAuthCredentials +

+

+(Appears on: +InfisicalAuth) +

+

+

+ + + + + + + + + + + + + + + + + +
FieldDescription
+clientId
+ + +External Secrets meta/v1.SecretKeySelector + + +
+
+clientSecret
+ + +External Secrets meta/v1.SecretKeySelector + + +
+

ValidationResult (byte alias)

diff --git a/docs/introduction/stability-support.md b/docs/introduction/stability-support.md index cabcd8710..eb639b0a5 100644 --- a/docs/introduction/stability-support.md +++ b/docs/introduction/stability-support.md @@ -55,6 +55,7 @@ The following table describes the stability level of each provider and who's res | [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 | | +| [Infisical](https://external-secrets.io/latest/provider/infisical) | alpha | [@akhilmhdh](https://github.com/akhilmhdh) | ## Provider Feature Support @@ -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 | | | +| Infisical | x | | | x | x | | | ## Support Policy diff --git a/docs/pictures/external-secrets-operator.png b/docs/pictures/external-secrets-operator.png new file mode 100644 index 000000000..5264eeea3 Binary files /dev/null and b/docs/pictures/external-secrets-operator.png differ diff --git a/docs/provider/hashicorp-vault.md b/docs/provider/hashicorp-vault.md index 27c271c32..ceeaa32d2 100644 --- a/docs/provider/hashicorp-vault.md +++ b/docs/provider/hashicorp-vault.md @@ -364,7 +364,7 @@ set of AWS Programmatic access credentials stored in a `Kind=Secret` and referen ### Mutual authentication (mTLS) -Under specific compliance requirements, the Vault server can be set up to enforce mutual authentication from clients across all APIs by configuring the server with `tls_require_and_verify_client_cert = true`. This configuration differs fundamentally from the [TLS certificates auth method](#TLS-certificates-authentication). While the TLS certificates auth method allows the issuance of a Vault token through the `/v1/auth/cert/login` API, the mTLS configuration solely focuses on TLS transport layer authentication and lacks any authorization-related capabilities. It's important to note that the Vault token must still be included in the request, following any of the supported authentication methods mentioned earlier. +Under specific compliance requirements, the Vault server can be set up to enforce mutual authentication from clients across all APIs by configuring the server with `tls_require_and_verify_client_cert = true`. This configuration differs fundamentally from the [TLS certificates auth method](#tls-certificates-authentication). While the TLS certificates auth method allows the issuance of a Vault token through the `/v1/auth/cert/login` API, the mTLS configuration solely focuses on TLS transport layer authentication and lacks any authorization-related capabilities. It's important to note that the Vault token must still be included in the request, following any of the supported authentication methods mentioned earlier. ```yaml {% include 'vault-mtls-store.yaml' %} diff --git a/docs/provider/infisical.md b/docs/provider/infisical.md new file mode 100644 index 000000000..617730bf9 --- /dev/null +++ b/docs/provider/infisical.md @@ -0,0 +1,68 @@ +![Infisical k8s Diagram](../pictures/external-secrets-operator.png) + +Sync secrets from [Infisical](https://www.infisical.com) to your Kubernetes cluster using External Secrets Operator. + +## Authentication +In order for the operator to fetch secrets from Infisical, it needs to first authenticate with Infisical. + +To authenticate, you can use [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) from [Machine identities](https://infisical.com/docs/documentation/platform/identities/machine-identities). + +Follow the [guide here](https://infisical.com/docs/documentation/platform/identities/universal-auth) to learn how to create and obtain a pair of Client Secret and Client ID. + +## Storing Your Machine Identity Secrets + +Once you have generated a pair of `Client ID` and `Client Secret`, you will need to store these credentials in your cluster as a Kubernetes secret. + +!!! note inline end + Remember to replace with your own Machine Identity credentials. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: universal-auth-credentials +type: Opaque + +stringData: + clientId: + clientSecret: +``` + +### Secret Store + +You will then need to create a generic `SecretStore`. An sample `SecretStore` has been is shown below. + +!!! tip inline end + To get your project slug from Infisical, head over to the project settings and click the button `Copy Project Slug`. + +```yaml +{% include 'infisical-generic-secret-store.yaml' %} +``` + +!!! Note + For `ClusterSecretStore`, be sure to set `namespace` in `universalAuthCredentials.clientId` and `universalAuthCredentials.clientSecret`. + +## Fetch Individual Secret(s) + +To sync one or more secrets individually, use the following YAML: + +```yaml +{% include 'infisical-fetch-secret.yaml' %} +``` + +## Fetch All Secrets + +To sync all secrets from an Infisical , use the following YAML: + +``` yaml +{% include 'infisical-fetch-all-secrets.yaml' %} +``` + +## Filter By Prefix/Name + +To filter secrets by `path` (path prefix) and `name` (regular expression). + +``` yaml +{% include 'infisical-filtered-secrets.yaml' %} +``` + diff --git a/docs/snippets/infisical-fetch-all-secrets.yaml b/docs/snippets/infisical-fetch-all-secrets.yaml new file mode 100644 index 000000000..e0ff9a5c9 --- /dev/null +++ b/docs/snippets/infisical-fetch-all-secrets.yaml @@ -0,0 +1,16 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: infisical-managed-secrets +spec: + secretStoreRef: + kind: SecretStore + name: infisical + + target: + name: auth-api + + dataFrom: + - find: + name: + regexp: .* diff --git a/docs/snippets/infisical-fetch-secret.yaml b/docs/snippets/infisical-fetch-secret.yaml new file mode 100644 index 000000000..eeb8153e3 --- /dev/null +++ b/docs/snippets/infisical-fetch-secret.yaml @@ -0,0 +1,16 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: infisical-managed-secrets +spec: + secretStoreRef: + kind: SecretStore + name: infisical + + target: + name: auth-api + + data: + - secretKey: API_KEY + remoteRef: + key: API_KEY diff --git a/docs/snippets/infisical-filtered-secrets.yaml b/docs/snippets/infisical-filtered-secrets.yaml new file mode 100644 index 000000000..51f7d4985 --- /dev/null +++ b/docs/snippets/infisical-filtered-secrets.yaml @@ -0,0 +1,15 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: infisical-managed-secrets +spec: + secretStoreRef: + kind: SecretStore + name: infisical + + target: + name: auth-api + + dataFrom: + - find: + path: DB_ diff --git a/docs/snippets/infisical-generic-secret-store.yaml b/docs/snippets/infisical-generic-secret-store.yaml new file mode 100644 index 000000000..c3f1e7c3b --- /dev/null +++ b/docs/snippets/infisical-generic-secret-store.yaml @@ -0,0 +1,25 @@ +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: infisical +spec: + provider: + infisical: + auth: + universalAuthCredentials: + clientId: + key: clientId + namespace: default + name: universal-auth-credentials + clientSecret: + key: clientSecret + namespace: default + name: universal-auth-credentials + # Details to pull secrets from + secretsScope: + projectSlug: first-project-fujo + environmentSlug: dev # "dev", "staging", "prod", etc.. + # optional + secretsPath: / # Root is "/" + # optional + hostAPI: https://app.infisical.com diff --git a/hack/api-docs/mkdocs.yml b/hack/api-docs/mkdocs.yml index 17367e81a..0d090e530 100644 --- a/hack/api-docs/mkdocs.yml +++ b/hack/api-docs/mkdocs.yml @@ -39,12 +39,12 @@ extra: property: G-QP38TD8K7V nav: - Introduction: - - Introduction: index.md - - Overview: introduction/overview.md - - Getting started: introduction/getting-started.md - - FAQ: introduction/faq.md - - Stability and Support: introduction/stability-support.md - - Deprecation Policy: introduction/deprecation-policy.md + - Introduction: index.md + - Overview: introduction/overview.md + - Getting started: introduction/getting-started.md + - FAQ: introduction/faq.md + - Stability and Support: introduction/stability-support.md + - Deprecation Policy: introduction/deprecation-policy.md - API: - Components: api/components.md - Core Resources: @@ -68,72 +68,73 @@ nav: - Controller Options: api/controller-options.md - Metrics: api/metrics.md - Guides: - - Introduction: guides/introduction.md - - External Secrets: - - Extract structured data: guides/all-keys-one-secret.md - - Find Secrets by Name or Metadata: guides/getallsecrets.md - - Rewriting Keys: guides/datafrom-rewrite.md - - Advanced Templating: - - v2: guides/templating.md - - v1: guides/templating-v1.md - - Kubernetes Secret Types: guides/common-k8s-secret-types.md - - "Lifecycle: ownership & deletion": guides/ownership-deletion-policy.md - - Decoding Strategies: guides/decoding-strategy.md - - Controller Classes: guides/controller-class.md - - Generators: guides/generator.md - - Push Secrets: guides/pushsecrets.md - - Operations: - - Multi Tenancy: guides/multi-tenancy.md - - Security Best Practices: guides/security-best-practices.md - - Threat Model: guides/threat-model.md - - Upgrading to v1beta1: guides/v1beta1.md - - Using Latest Image: guides/using-latest-image.md - - Disable Cluster Features: guides/disable-cluster-features.md + - Introduction: guides/introduction.md + - External Secrets: + - Extract structured data: guides/all-keys-one-secret.md + - Find Secrets by Name or Metadata: guides/getallsecrets.md + - Rewriting Keys: guides/datafrom-rewrite.md + - Advanced Templating: + - v2: guides/templating.md + - v1: guides/templating-v1.md + - Kubernetes Secret Types: guides/common-k8s-secret-types.md + - "Lifecycle: ownership & deletion": guides/ownership-deletion-policy.md + - Decoding Strategies: guides/decoding-strategy.md + - Controller Classes: guides/controller-class.md + - Generators: guides/generator.md + - Push Secrets: guides/pushsecrets.md + - Operations: + - Multi Tenancy: guides/multi-tenancy.md + - Security Best Practices: guides/security-best-practices.md + - Threat Model: guides/threat-model.md + - Upgrading to v1beta1: guides/v1beta1.md + - Using Latest Image: guides/using-latest-image.md + - Disable Cluster Features: guides/disable-cluster-features.md - Provider: - - AWS Secrets Manager: provider/aws-secrets-manager.md - - AWS Parameter Store: provider/aws-parameter-store.md - - Azure Key Vault: provider/azure-key-vault.md - - Chef: provider/chef.md - - CyberArk Conjur: provider/conjur.md - - Google Cloud Secret Manager: provider/google-secrets-manager.md - - HashiCorp Vault: provider/hashicorp-vault.md - - Kubernetes: provider/kubernetes.md - - IBM Secrets Manager: provider/ibm-secrets-manager.md - - Akeyless: provider/akeyless.md - - Yandex Certificate Manager: provider/yandex-certificate-manager.md - - Yandex Lockbox: provider/yandex-lockbox.md - - Alibaba Cloud: provider/alibaba.md - - GitLab Variables: provider/gitlab-variables.md - - Oracle Vault: provider/oracle-vault.md - - 1Password Secrets Automation: provider/1password-automation.md - - Webhook: provider/webhook.md - - Fake: provider/fake.md - - senhasegura DevOps Secrets Management (DSM): provider/senhasegura-dsm.md - - Doppler: provider/doppler.md - - Keeper Security: provider/keeper-security.md - - Cloak End 2 End Encrypted Secrets: provider/cloak.md - - Scaleway: provider/scaleway.md - - Delinea: provider/delinea.md - - Passbolt: provider/passbolt.md - - Pulumi ESC: provider/pulumi.md - - Onboardbase: provider/onboardbase.md - - Password Depot: provider-passworddepot.md - - Fortanix: provider/fortanix.md + - AWS Secrets Manager: provider/aws-secrets-manager.md + - AWS Parameter Store: provider/aws-parameter-store.md + - Azure Key Vault: provider/azure-key-vault.md + - Chef: provider/chef.md + - CyberArk Conjur: provider/conjur.md + - Google Cloud Secret Manager: provider/google-secrets-manager.md + - HashiCorp Vault: provider/hashicorp-vault.md + - Kubernetes: provider/kubernetes.md + - IBM Secrets Manager: provider/ibm-secrets-manager.md + - Akeyless: provider/akeyless.md + - Yandex Certificate Manager: provider/yandex-certificate-manager.md + - Yandex Lockbox: provider/yandex-lockbox.md + - Alibaba Cloud: provider/alibaba.md + - GitLab Variables: provider/gitlab-variables.md + - Oracle Vault: provider/oracle-vault.md + - 1Password Secrets Automation: provider/1password-automation.md + - Webhook: provider/webhook.md + - Fake: provider/fake.md + - senhasegura DevOps Secrets Management (DSM): provider/senhasegura-dsm.md + - Doppler: provider/doppler.md + - Keeper Security: provider/keeper-security.md + - Cloak End 2 End Encrypted Secrets: provider/cloak.md + - Scaleway: provider/scaleway.md + - Delinea: provider/delinea.md + - Passbolt: provider/passbolt.md + - Pulumi ESC: provider/pulumi.md + - Onboardbase: provider/onboardbase.md + - Password Depot: provider-passworddepot.md + - Fortanix: provider/fortanix.md + - Infisical: provider/infisical.md - Examples: - - FluxCD: examples/gitops-using-fluxcd.md - - Anchore Engine: examples/anchore-engine-credentials.md - - Jenkins: examples/jenkins-kubernetes-credentials.md - - BitWarden: examples/bitwarden.md + - FluxCD: examples/gitops-using-fluxcd.md + - Anchore Engine: examples/anchore-engine-credentials.md + - Jenkins: examples/jenkins-kubernetes-credentials.md + - BitWarden: examples/bitwarden.md - Community: - - Contributing: - - Developer guide: contributing/devguide.md - - Contributing Process: contributing/process.md - - Release Process: contributing/release.md - - Code of Conduct: contributing/coc.md - - Roadmap: contributing/roadmap.md - - External Resources: - - Talks: eso-talks.md - - Demos: eso-demos.md - - Blogs: eso-blogs.md + - Contributing: + - Developer guide: contributing/devguide.md + - Contributing Process: contributing/process.md + - Release Process: contributing/release.md + - Code of Conduct: contributing/coc.md + - Roadmap: contributing/roadmap.md + - External Resources: + - Talks: eso-talks.md + - Demos: eso-demos.md + - Blogs: eso-blogs.md - References: - - API specification: spec.md + - API specification: spec.md diff --git a/pkg/provider/infisical/api/api.go b/pkg/provider/infisical/api/api.go new file mode 100644 index 000000000..298463c6d --- /dev/null +++ b/pkg/provider/infisical/api/api.go @@ -0,0 +1,257 @@ +/* +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 impliec. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + "github.com/external-secrets/external-secrets/pkg/metrics" + "github.com/external-secrets/external-secrets/pkg/provider/infisical/constants" +) + +type InfisicalClient struct { + BaseURL *url.URL + client *http.Client + token string +} + +type InfisicalApis interface { + MachineIdentityLoginViaUniversalAuth(data MachineIdentityUniversalAuthLoginRequest) (*MachineIdentityDetailsResponse, error) + GetSecretsV3(data GetSecretsV3Request) (map[string]string, error) + GetSecretByKeyV3(data GetSecretByKeyV3Request) (string, error) + RevokeAccessToken() error +} + +const UserAgentName = "k8-external-secrets-operator" +const errJSONSecretUnmarshal = "unable to unmarshal secret: %w" + +func NewAPIClient(baseURL string) (*InfisicalClient, error) { + baseParsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + api := &InfisicalClient{ + BaseURL: baseParsedURL, + client: &http.Client{ + Timeout: time.Second * 15, + }, + } + + return api, nil +} + +func (a *InfisicalClient) SetTokenViaMachineIdentity(clientID, clientSecret string) error { + if a.token != "" { + return nil + } + + loginResponse, err := a.MachineIdentityLoginViaUniversalAuth(MachineIdentityUniversalAuthLoginRequest{ + ClientID: clientID, + ClientSecret: clientSecret, + }) + if err != nil { + return err + } + + a.token = loginResponse.AccessToken + return nil +} + +func (a *InfisicalClient) RevokeAccessToken() error { + if a.token == "" { + return nil + } + if _, err := a.RevokeMachineIdentityAccessToken(RevokeMachineIdentityAccessTokenRequest{AccessToken: a.token}); err != nil { + return err + } + + a.token = "" + return nil +} + +func (a *InfisicalClient) resolveEndpoint(path string) string { + return a.BaseURL.ResolveReference(&url.URL{Path: path}).String() +} + +func (a *InfisicalClient) do(r *http.Request) (*http.Response, error) { + if a.token != "" { + r.Header.Add("Authorization", "Bearer "+a.token) + } + r.Header.Add("User-Agent", UserAgentName) + r.Header.Add("Content-Type", "application/json") + + return a.client.Do(r) +} + +func (a *InfisicalClient) MachineIdentityLoginViaUniversalAuth(data MachineIdentityUniversalAuthLoginRequest) (*MachineIdentityDetailsResponse, error) { + endpointURL := a.resolveEndpoint("api/v1/auth/universal-auth/login") + body, err := MarshalReqBody(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, endpointURL, body) + metrics.ObserveAPICall(constants.ProviderName, "MachineIdentityLoginViaUniversalAuth", err) + if err != nil { + return nil, err + } + + rawRes, err := a.do(req) + if err != nil { + return nil, err + } + + var res MachineIdentityDetailsResponse + err = ReadAndUnmarshal(rawRes, &res) + if err != nil { + return nil, fmt.Errorf(errJSONSecretUnmarshal, err) + } + return &res, nil +} + +func (a *InfisicalClient) RevokeMachineIdentityAccessToken(data RevokeMachineIdentityAccessTokenRequest) (*RevokeMachineIdentityAccessTokenResponse, error) { + endpointURL := a.resolveEndpoint("api/v1/auth/token/revoke") + body, err := MarshalReqBody(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, endpointURL, body) + metrics.ObserveAPICall(constants.ProviderName, "RevokeMachineIdentityAccessToken", err) + if err != nil { + return nil, err + } + + rawRes, err := a.do(req) + if err != nil { + return nil, err + } + + var res RevokeMachineIdentityAccessTokenResponse + err = ReadAndUnmarshal(rawRes, &res) + if err != nil { + return nil, fmt.Errorf(errJSONSecretUnmarshal, err) + } + return &res, nil +} + +func (a *InfisicalClient) GetSecretsV3(data GetSecretsV3Request) (map[string]string, error) { + endpointURL := a.resolveEndpoint("api/v3/secrets/raw") + + req, err := http.NewRequest(http.MethodGet, endpointURL, http.NoBody) + metrics.ObserveAPICall(constants.ProviderName, "GetSecretsV3", err) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("workspaceSlug", data.ProjectSlug) + q.Add("environment", data.EnvironmentSlug) + q.Add("secretPath", data.SecretPath) + q.Add("include_imports", "true") + q.Add("expandSecretReferences", "true") + req.URL.RawQuery = q.Encode() + + rawRes, err := a.do(req) + if err != nil { + return nil, err + } + + var res GetSecretsV3Response + err = ReadAndUnmarshal(rawRes, &res) + if err != nil { + return nil, fmt.Errorf(errJSONSecretUnmarshal, err) + } + + secrets := make(map[string]string) + for _, s := range res.ImportedSecrets { + for _, el := range s.Secrets { + secrets[el.SecretKey] = el.SecretValue + } + } + for _, el := range res.Secrets { + secrets[el.SecretKey] = el.SecretValue + } + + return secrets, nil +} + +func (a *InfisicalClient) GetSecretByKeyV3(data GetSecretByKeyV3Request) (string, error) { + endpointURL := a.resolveEndpoint(fmt.Sprintf("api/v3/secrets/raw/%s", data.SecretKey)) + + req, err := http.NewRequest(http.MethodGet, endpointURL, http.NoBody) + metrics.ObserveAPICall(constants.ProviderName, "GetSecretByKeyV3", err) + if err != nil { + return "", err + } + + q := req.URL.Query() + q.Add("workspaceSlug", data.ProjectSlug) + q.Add("environment", data.EnvironmentSlug) + q.Add("secretPath", data.SecretPath) + q.Add("include_imports", "true") + req.URL.RawQuery = q.Encode() + + rawRes, err := a.do(req) + if err != nil { + return "", err + } + if rawRes.StatusCode == 400 { + var errRes InfisicalAPIErrorResponse + err = ReadAndUnmarshal(rawRes, &errRes) + if err != nil { + return "", fmt.Errorf(errJSONSecretUnmarshal, err) + } + + if errRes.Message == "Secret not found" { + return "", esv1beta1.NoSecretError{} + } + return "", errors.New(errRes.Message) + } + + var res GetSecretByKeyV3Response + err = ReadAndUnmarshal(rawRes, &res) + if err != nil { + return "", fmt.Errorf(errJSONSecretUnmarshal, err) + } + + return res.Secret.SecretValue, nil +} + +func MarshalReqBody(data any) (*bytes.Reader, error) { + body, err := json.Marshal(data) + if err != nil { + return nil, err + } + return bytes.NewReader(body), nil +} + +func ReadAndUnmarshal(resp *http.Response, target any) error { + var buf bytes.Buffer + defer resp.Body.Close() + _, err := buf.ReadFrom(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(buf.Bytes(), target) +} diff --git a/pkg/provider/infisical/api/api_models.go b/pkg/provider/infisical/api/api_models.go new file mode 100644 index 000000000..f45ca88b3 --- /dev/null +++ b/pkg/provider/infisical/api/api_models.go @@ -0,0 +1,87 @@ +/* +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 impliec. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +type MachineIdentityUniversalAuthRefreshRequest struct { + AccessToken string `json:"accessToken"` +} + +type MachineIdentityDetailsResponse struct { + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` + AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` + TokenType string `json:"tokenType"` +} + +type MachineIdentityUniversalAuthLoginRequest struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +type RevokeMachineIdentityAccessTokenRequest struct { + AccessToken string `json:"accessToken"` +} + +type RevokeMachineIdentityAccessTokenResponse struct { + Message string `json:"message"` +} + +type GetSecretByKeyV3Request struct { + EnvironmentSlug string `json:"environment"` + ProjectSlug string `json:"workspaceSlug"` + SecretPath string `json:"secretPath"` + SecretKey string `json:"secretKey"` +} + +type GetSecretByKeyV3Response struct { + Secret SecretsV3 `json:"secret"` +} + +type GetSecretsV3Request struct { + EnvironmentSlug string `json:"environment"` + ProjectSlug string `json:"workspaceSlug"` + SecretPath string `json:"secretPath"` +} + +type GetSecretsV3Response struct { + Secrets []SecretsV3 `json:"secrets"` + ImportedSecrets []ImportedSecretV3 `json:"imports,omitempty"` + Modified bool `json:"modified,omitempty"` + ETag string `json:"ETag,omitempty"` +} + +type SecretsV3 struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + Environment string `json:"environment"` + Version int `json:"version"` + Type string `json:"string"` + SecretKey string `json:"secretKey"` + SecretValue string `json:"secretValue"` + SecretComment string `json:"secretComment"` +} + +type ImportedSecretV3 struct { + Environment string `json:"environment"` + FolderID string `json:"folderId"` + SecretPath string `json:"secretPath"` + Secrets []SecretsV3 `json:"secrets"` +} + +type InfisicalAPIErrorResponse struct { + StatusCode int `json:"statusCode"` + Message string `json:"message"` + Error any `json:"error"` +} diff --git a/pkg/provider/infisical/client.go b/pkg/provider/infisical/client.go new file mode 100644 index 000000000..1df252560 --- /dev/null +++ b/pkg/provider/infisical/client.go @@ -0,0 +1,170 @@ +/* +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 impliec. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package infisical + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "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/find" + "github.com/external-secrets/external-secrets/pkg/provider/infisical/api" +) + +var ( + errNotImplemented = errors.New("not implemented") + errPropertyNotFound = "property %s does not exist in secret %s" + errTagsNotImplemented = errors.New("find by tags not supported") +) + +func getPropertyValue(jsonData, propertyName, keyName string) ([]byte, error) { + result := gjson.Get(jsonData, propertyName) + if !result.Exists() { + return nil, fmt.Errorf(errPropertyNotFound, propertyName, keyName) + } + return []byte(result.Str), nil +} + +// if GetSecret returns an error with type NoSecretError. +// then the secret entry will be deleted depending on the deletionPolicy. +func (p *Provider) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) { + secret, err := p.apiClient.GetSecretByKeyV3(api.GetSecretByKeyV3Request{ + EnvironmentSlug: p.apiScope.EnvironmentSlug, + ProjectSlug: p.apiScope.ProjectSlug, + SecretPath: p.apiScope.SecretPath, + SecretKey: ref.Key, + }) + + if err != nil { + return nil, err + } + + if ref.Property != "" { + propertyValue, err := getPropertyValue(secret, ref.Property, ref.Key) + if err != nil { + return nil, err + } + + return propertyValue, nil + } + + return []byte(secret), nil +} + +// GetSecretMap returns multiple k/v pairs from the provider. +func (p *Provider) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) { + secret, err := p.GetSecret(ctx, ref) + if err != nil { + return nil, err + } + + kv := make(map[string]json.RawMessage) + err = json.Unmarshal(secret, &kv) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal secret %s: %w", ref.Key, err) + } + secretData := make(map[string][]byte) + for k, v := range kv { + var strVal string + err = json.Unmarshal(v, &strVal) + if err == nil { + secretData[k] = []byte(strVal) + } else { + secretData[k] = v + } + } + return secretData, nil +} + +// GetAllSecrets returns multiple k/v pairs from the provider. +func (p *Provider) GetAllSecrets(ctx context.Context, ref esv1beta1.ExternalSecretFind) (map[string][]byte, error) { + if ref.Tags != nil { + return nil, errTagsNotImplemented + } + + secrets, err := p.apiClient.GetSecretsV3(api.GetSecretsV3Request{ + EnvironmentSlug: p.apiScope.EnvironmentSlug, + ProjectSlug: p.apiScope.ProjectSlug, + SecretPath: p.apiScope.SecretPath, + }) + if err != nil { + return nil, err + } + + secretMap := make(map[string][]byte) + for key, value := range secrets { + secretMap[key] = []byte(value) + } + if ref.Name == nil && ref.Path == nil { + return secretMap, nil + } + + var matcher *find.Matcher + if ref.Name != nil { + m, err := find.New(*ref.Name) + if err != nil { + return nil, err + } + matcher = m + } + + selected := map[string][]byte{} + for key, value := range secrets { + if (matcher != nil && !matcher.MatchName(key)) || (ref.Path != nil && !strings.HasPrefix(key, *ref.Path)) { + continue + } + selected[key] = []byte(value) + } + return selected, nil +} + +// Validate checks if the client is configured correctly. +// and is able to retrieve secrets from the provider. +// If the validation result is unknown it will be ignored. +func (p *Provider) Validate() (esv1beta1.ValidationResult, error) { + // try to fetch the secrets to ensure provided credentials has access to read secrets + _, err := p.apiClient.GetSecretsV3(api.GetSecretsV3Request{ + EnvironmentSlug: p.apiScope.EnvironmentSlug, + ProjectSlug: p.apiScope.ProjectSlug, + SecretPath: p.apiScope.SecretPath, + }) + + if err != nil { + return esv1beta1.ValidationResultError, fmt.Errorf("cannot read secrets with provided project scope project:%s environment:%s secret-path:%s, %w", p.apiScope.ProjectSlug, p.apiScope.EnvironmentSlug, p.apiScope.SecretPath, err) + } + + return esv1beta1.ValidationResultReady, nil +} + +// PushSecret will write a single secret into the provider. +func (p *Provider) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1beta1.PushSecretData) error { + return errNotImplemented +} + +// DeleteSecret will delete the secret from a provider. +func (p *Provider) DeleteSecret(ctx context.Context, remoteRef esv1beta1.PushSecretRemoteRef) error { + return errNotImplemented +} + +// SecretExists checks if a secret is already present in the provider at the given location. +func (p *Provider) SecretExists(ctx context.Context, remoteRef esv1beta1.PushSecretRemoteRef) (bool, error) { + return false, errNotImplemented +} diff --git a/pkg/provider/infisical/constants/constants.go b/pkg/provider/infisical/constants/constants.go new file mode 100644 index 000000000..987fbe370 --- /dev/null +++ b/pkg/provider/infisical/constants/constants.go @@ -0,0 +1,19 @@ +/* +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 impliec. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package constants + +const ( + UniversalAuth = "universal-auth" + ProviderName = "infisical" +) diff --git a/pkg/provider/infisical/fake/fake.go b/pkg/provider/infisical/fake/fake.go new file mode 100644 index 000000000..888f8c74b --- /dev/null +++ b/pkg/provider/infisical/fake/fake.go @@ -0,0 +1,58 @@ +/* +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 impliec. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package fake + +import ( + "errors" + "time" + + "github.com/external-secrets/external-secrets/pkg/provider/infisical/api" +) + +var ( + ErrMissingMockImplementation = errors.New("missing mock implmentation") +) + +type MockInfisicalClient struct { + MockedGetSecretV3 func(data api.GetSecretsV3Request) (map[string]string, error) + MockedGetSecretByKeyV3 func(data api.GetSecretByKeyV3Request) (string, error) +} + +func (a *MockInfisicalClient) MachineIdentityLoginViaUniversalAuth(data api.MachineIdentityUniversalAuthLoginRequest) (*api.MachineIdentityDetailsResponse, error) { + return &api.MachineIdentityDetailsResponse{ + AccessToken: "test-access-token", + ExpiresIn: int(time.Hour * 24), + TokenType: "bearer", + AccessTokenMaxTTL: int(time.Hour * 24 * 2), + }, nil +} + +func (a *MockInfisicalClient) GetSecretsV3(data api.GetSecretsV3Request) (map[string]string, error) { + if a.MockedGetSecretV3 == nil { + return nil, ErrMissingMockImplementation + } + + return a.MockedGetSecretV3(data) +} + +func (a *MockInfisicalClient) GetSecretByKeyV3(data api.GetSecretByKeyV3Request) (string, error) { + if a.MockedGetSecretByKeyV3 == nil { + return "", ErrMissingMockImplementation + } + return a.MockedGetSecretByKeyV3(data) +} + +func (a *MockInfisicalClient) RevokeAccessToken() error { + return nil +} diff --git a/pkg/provider/infisical/provider.go b/pkg/provider/infisical/provider.go new file mode 100644 index 000000000..3fd7f90ec --- /dev/null +++ b/pkg/provider/infisical/provider.go @@ -0,0 +1,159 @@ +/* +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 implieclient. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package infisical + +import ( + "context" + "errors" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + 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" + esmeta "github.com/external-secrets/external-secrets/apis/meta/v1" + "github.com/external-secrets/external-secrets/pkg/provider/infisical/api" + "github.com/external-secrets/external-secrets/pkg/provider/infisical/constants" + "github.com/external-secrets/external-secrets/pkg/utils" + "github.com/external-secrets/external-secrets/pkg/utils/resolvers" +) + +var ( + Logger = ctrl.Log.WithName("provider").WithName(constants.ProviderName) +) + +type Provider struct { + apiClient api.InfisicalApis + apiScope *InfisicalClientScope +} + +type InfisicalClientScope struct { + SecretPath string + ProjectSlug string + EnvironmentSlug string +} + +// https://github.com/external-secrets/external-secrets/issues/644 +var _ esv1beta1.SecretsClient = &Provider{} +var _ esv1beta1.Provider = &Provider{} + +func init() { + esv1beta1.Register(&Provider{}, &esv1beta1.SecretStoreProvider{ + Infisical: &esv1beta1.InfisicalProvider{}, + }) +} + +func (p *Provider) Capabilities() esv1beta1.SecretStoreCapabilities { + return esv1beta1.SecretStoreReadOnly +} + +func (p *Provider) NewClient(ctx context.Context, store esv1beta1.GenericStore, kube kclient.Client, namespace string) (esv1beta1.SecretsClient, error) { + storeSpec := store.GetSpec() + + if storeSpec == nil || storeSpec.Provider == nil || storeSpec.Provider.Infisical == nil { + return nil, errors.New("invalid infisical store") + } + + infisicalSpec := storeSpec.Provider.Infisical + + apiClient, err := api.NewAPIClient(infisicalSpec.HostAPI) + if err != nil { + return nil, err + } + + if infisicalSpec.Auth.UniversalAuthCredentials != nil { + universalAuthCredentials := infisicalSpec.Auth.UniversalAuthCredentials + clientID, err := GetStoreSecretData(ctx, store, kube, namespace, universalAuthCredentials.ClientID) + if err != nil { + return nil, err + } + + clientSecret, err := GetStoreSecretData(ctx, store, kube, namespace, universalAuthCredentials.ClientSecret) + if err != nil { + return nil, err + } + + if err := apiClient.SetTokenViaMachineIdentity(clientID, clientSecret); err != nil { + return nil, fmt.Errorf("failed to authenticate via universal auth %w", err) + } + + return &Provider{ + apiClient: apiClient, + apiScope: &InfisicalClientScope{ + SecretPath: infisicalSpec.SecretsScope.SecretsPath, + ProjectSlug: infisicalSpec.SecretsScope.ProjectSlug, + EnvironmentSlug: infisicalSpec.SecretsScope.EnvironmentSlug, + }, + }, nil + } + + return &Provider{}, errors.New("authentication method not found") +} + +func (p *Provider) Close(ctx context.Context) error { + if err := p.apiClient.RevokeAccessToken(); err != nil { + return err + } + return nil +} + +func GetStoreSecretData(ctx context.Context, store esv1beta1.GenericStore, kube kclient.Client, namespace string, secret esmeta.SecretKeySelector) (string, error) { + secretRef := esmeta.SecretKeySelector{ + Name: secret.Name, + Key: secret.Key, + } + if secret.Namespace != nil { + secretRef.Namespace = secret.Namespace + } + + secretData, err := resolvers.SecretKeyRef(ctx, kube, store.GetObjectKind().GroupVersionKind().Kind, namespace, &secretRef) + if err != nil { + return "", err + } + return secretData, nil +} + +func (p *Provider) ValidateStore(store esv1beta1.GenericStore) (admission.Warnings, error) { + storeSpec := store.GetSpec() + infisicalStoreSpec := storeSpec.Provider.Infisical + if infisicalStoreSpec == nil { + return nil, errors.New("invalid infisical store") + } + + if infisicalStoreSpec.SecretsScope.EnvironmentSlug == "" || infisicalStoreSpec.SecretsScope.ProjectSlug == "" { + return nil, errors.New("secretsScope.projectSlug and secretsScope.environmentSlug cannot be empty") + } + + if infisicalStoreSpec.Auth.UniversalAuthCredentials != nil { + uaCredential := infisicalStoreSpec.Auth.UniversalAuthCredentials + // to validate reference authentication + err := utils.ValidateReferentSecretSelector(store, uaCredential.ClientID) + if err != nil { + return nil, err + } + + err = utils.ValidateReferentSecretSelector(store, uaCredential.ClientSecret) + if err != nil { + return nil, err + } + + if uaCredential.ClientID.Key == "" || uaCredential.ClientSecret.Key == "" { + return nil, errors.New("universalAuthCredentials.clientId and universalAuthCredentials.clientSecret cannot be empty") + } + } + + return nil, nil +} diff --git a/pkg/provider/infisical/provider_test.go b/pkg/provider/infisical/provider_test.go new file mode 100644 index 000000000..24fb34502 --- /dev/null +++ b/pkg/provider/infisical/provider_test.go @@ -0,0 +1,238 @@ +/* +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 impliec. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package infisical + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + esv1meta "github.com/external-secrets/external-secrets/apis/meta/v1" + "github.com/external-secrets/external-secrets/pkg/provider/infisical/api" + "github.com/external-secrets/external-secrets/pkg/provider/infisical/fake" +) + +type storeModifier func(*esv1beta1.SecretStore) *esv1beta1.SecretStore + +var apiScope = InfisicalClientScope{ + SecretPath: "/", + ProjectSlug: "first-project", + EnvironmentSlug: "dev", +} + +type TestCases struct { + Name string + MockClient *fake.MockInfisicalClient + PropertyAccess string + Error error + Output any +} + +func TestGetSecret(t *testing.T) { + testCases := []TestCases{ + { + Name: "Get_valid_key", + MockClient: &fake.MockInfisicalClient{ + MockedGetSecretByKeyV3: func(data api.GetSecretByKeyV3Request) (string, error) { + return "value", nil + }, + }, + Error: nil, + Output: []byte("value"), + }, + { + Name: "Get_property_key", + MockClient: &fake.MockInfisicalClient{ + MockedGetSecretByKeyV3: func(data api.GetSecretByKeyV3Request) (string, error) { + return `{"key":"value"}`, nil + }, + }, + Error: nil, + Output: []byte("value"), + }, + { + Name: "Key_not_found", + MockClient: &fake.MockInfisicalClient{ + MockedGetSecretByKeyV3: func(data api.GetSecretByKeyV3Request) (string, error) { + // from server + return "", errors.New("Secret not found") + }, + }, + Error: errors.New("Secret not found"), + Output: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + p := &Provider{ + apiClient: tc.MockClient, + apiScope: &apiScope, + } + var property string + if tc.Name == "Get_property_key" { + property = "key" + } + + output, err := p.GetSecret(context.Background(), esv1beta1.ExternalSecretDataRemoteRef{ + Key: "key", + Property: property, + }) + + if tc.Error == nil { + assert.NoError(t, err) + assert.Equal(t, tc.Output, output) + } else { + assert.ErrorAs(t, err, &tc.Error) + } + }) + } +} + +func TestGetSecretMap(t *testing.T) { + testCases := []TestCases{ + { + Name: "Get_valid_key_map", + MockClient: &fake.MockInfisicalClient{ + MockedGetSecretByKeyV3: func(data api.GetSecretByKeyV3Request) (string, error) { + return `{"key":"value"}`, nil + }, + }, + Error: nil, + Output: map[string][]byte{ + "key": []byte("value"), + }, + }, + { + Name: "Get_invalid_map", + MockClient: &fake.MockInfisicalClient{ + MockedGetSecretByKeyV3: func(data api.GetSecretByKeyV3Request) (string, error) { + return ``, nil + }, + }, + Error: errors.New("unexpected end of JSON input"), + Output: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + p := &Provider{ + apiClient: tc.MockClient, + apiScope: &apiScope, + } + output, err := p.GetSecretMap(context.Background(), esv1beta1.ExternalSecretDataRemoteRef{ + Key: "key", + }) + if tc.Error == nil { + assert.NoError(t, err) + assert.Equal(t, tc.Output, output) + } else { + assert.ErrorAs(t, err, &tc.Error) + } + }) + } +} + +func makeSecretStore(projectSlug, environment, secretPath string, fn ...storeModifier) *esv1beta1.SecretStore { + store := &esv1beta1.SecretStore{ + Spec: esv1beta1.SecretStoreSpec{ + Provider: &esv1beta1.SecretStoreProvider{ + Infisical: &esv1beta1.InfisicalProvider{ + Auth: esv1beta1.InfisicalAuth{ + UniversalAuthCredentials: &esv1beta1.UniversalAuthCredentials{}, + }, + SecretsScope: esv1beta1.MachineIdentityScopeInWorkspace{ + SecretsPath: secretPath, + EnvironmentSlug: environment, + ProjectSlug: projectSlug, + }, + }, + }, + }, + } + for _, f := range fn { + store = f(store) + } + return store +} + +func withClientID(name, key string, namespace *string) storeModifier { + return func(store *esv1beta1.SecretStore) *esv1beta1.SecretStore { + store.Spec.Provider.Infisical.Auth.UniversalAuthCredentials.ClientID = esv1meta.SecretKeySelector{ + Name: name, + Key: key, + Namespace: namespace, + } + return store + } +} + +func withClientSecret(name, key string, namespace *string) storeModifier { + return func(store *esv1beta1.SecretStore) *esv1beta1.SecretStore { + store.Spec.Provider.Infisical.Auth.UniversalAuthCredentials.ClientSecret = esv1meta.SecretKeySelector{ + Name: name, + Key: key, + Namespace: namespace, + } + return store + } +} + +type ValidateStoreTestCase struct { + store *esv1beta1.SecretStore + assertError func(t *testing.T, err error) +} + +func TestValidateStore(t *testing.T) { + const randomID = "some-random-id" + const authType = "universal-auth" + var authCredMissingErr = errors.New("universalAuthCredentials.clientId and universalAuthCredentials.clientSecret cannot be empty") + var authScopeMissingErr = errors.New("secretsScope.projectSlug and secretsScope.environmentSlug cannot be empty") + + testCases := []ValidateStoreTestCase{ + { + store: makeSecretStore("", "", ""), + assertError: func(t *testing.T, err error) { + require.ErrorAs(t, err, &authScopeMissingErr) + }, + }, + { + store: makeSecretStore(apiScope.ProjectSlug, apiScope.EnvironmentSlug, apiScope.SecretPath, withClientID(authType, randomID, nil)), + assertError: func(t *testing.T, err error) { + require.ErrorAs(t, err, &authCredMissingErr) + }, + }, + { + store: makeSecretStore(apiScope.ProjectSlug, apiScope.EnvironmentSlug, apiScope.SecretPath, withClientSecret(authType, randomID, nil)), + assertError: func(t *testing.T, err error) { + require.ErrorAs(t, err, &authCredMissingErr) + }, + }, + { + store: makeSecretStore(apiScope.ProjectSlug, apiScope.EnvironmentSlug, apiScope.SecretPath, withClientID(authType, randomID, nil), withClientSecret(authType, randomID, nil)), + assertError: func(t *testing.T, err error) { require.NoError(t, err) }, + }, + } + p := Provider{} + for _, tc := range testCases { + _, err := p.ValidateStore(tc.store) + tc.assertError(t, err) + } +} diff --git a/pkg/provider/register/register.go b/pkg/provider/register/register.go index 80e0e4019..1b339c996 100644 --- a/pkg/provider/register/register.go +++ b/pkg/provider/register/register.go @@ -30,6 +30,7 @@ import ( _ "github.com/external-secrets/external-secrets/pkg/provider/gcp/secretmanager" _ "github.com/external-secrets/external-secrets/pkg/provider/gitlab" _ "github.com/external-secrets/external-secrets/pkg/provider/ibm" + _ "github.com/external-secrets/external-secrets/pkg/provider/infisical" _ "github.com/external-secrets/external-secrets/pkg/provider/keepersecurity" _ "github.com/external-secrets/external-secrets/pkg/provider/kubernetes" _ "github.com/external-secrets/external-secrets/pkg/provider/onboardbase"