diff --git a/Makefile b/Makefile index 3badfccca..f7b8d1653 100644 --- a/Makefile +++ b/Makefile @@ -146,6 +146,7 @@ run: generate ## Run app locally (without a k8s cluster) manifests: helm.generate ## Generate manifests from helm chart mkdir -p $(OUTPUT_DIR)/deploy/manifests + helm dependency build $(HELM_DIR) helm template external-secrets $(HELM_DIR) -f deploy/manifests/helm-values.yaml > $(OUTPUT_DIR)/deploy/manifests/external-secrets.yaml crds.install: generate ## Install CRDs into a cluster. This is for convenience diff --git a/apis/externalsecrets/v1beta1/secretsstore_bitwarden_types.go b/apis/externalsecrets/v1beta1/secretsstore_bitwarden_types.go new file mode 100644 index 000000000..aec55cf62 --- /dev/null +++ b/apis/externalsecrets/v1beta1/secretsstore_bitwarden_types.go @@ -0,0 +1,47 @@ +/* +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" +) + +// BitwardenSecretsManagerProvider configures a store to sync secrets with a Bitwarden Secrets Manager instance. +type BitwardenSecretsManagerProvider struct { + APIURL string `json:"apiURL,omitempty"` + IdentityURL string `json:"identityURL,omitempty"` + BitwardenServerSDKURL string `json:"bitwardenServerSDKURL,omitempty"` + // Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack + // can be performed. + // +required + CABundle string `json:"caBundle"` + // OrganizationID determines which organization this secret store manages. + OrganizationID string `json:"organizationID"` + // ProjectID determines which project this secret store manages. + ProjectID string `json:"projectID"` + // Auth configures how secret-manager authenticates with a bitwarden machine account instance. + // Make sure that the token being used has permissions on the given secret. + Auth BitwardenSecretsManagerAuth `json:"auth"` +} + +// BitwardenSecretsManagerAuth contains the ref to the secret that contains the machine account token. +type BitwardenSecretsManagerAuth struct { + SecretRef BitwardenSecretsManagerSecretRef `json:"secretRef"` +} + +// BitwardenSecretsManagerSecretRef contains the credential ref to the bitwarden instance. +type BitwardenSecretsManagerSecretRef struct { + // AccessToken used for the bitwarden instance. + // +required + Credentials esmeta.SecretKeySelector `json:"credentials"` +} diff --git a/apis/externalsecrets/v1beta1/secretstore_types.go b/apis/externalsecrets/v1beta1/secretstore_types.go index 31346b430..112e58860 100644 --- a/apis/externalsecrets/v1beta1/secretstore_types.go +++ b/apis/externalsecrets/v1beta1/secretstore_types.go @@ -74,6 +74,10 @@ type SecretStoreProvider struct { // +optional Akeyless *AkeylessProvider `json:"akeyless,omitempty"` + // BitwardenSecretsManager configures this store to sync secrets using BitwardenSecretsManager provider + // +optional + BitwardenSecretsManager *BitwardenSecretsManagerProvider `json:"bitwardensecretsmanager,omitempty"` + // Vault configures this store to sync secrets using Hashi provider // +optional Vault *VaultProvider `json:"vault,omitempty"` diff --git a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go index fae7037ea..31c591118 100644 --- a/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go +++ b/apis/externalsecrets/v1beta1/zz_generated.deepcopy.go @@ -391,6 +391,54 @@ func (in *AzureKVProvider) DeepCopy() *AzureKVProvider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitwardenSecretsManagerAuth) DeepCopyInto(out *BitwardenSecretsManagerAuth) { + *out = *in + in.SecretRef.DeepCopyInto(&out.SecretRef) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitwardenSecretsManagerAuth. +func (in *BitwardenSecretsManagerAuth) DeepCopy() *BitwardenSecretsManagerAuth { + if in == nil { + return nil + } + out := new(BitwardenSecretsManagerAuth) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitwardenSecretsManagerProvider) DeepCopyInto(out *BitwardenSecretsManagerProvider) { + *out = *in + in.Auth.DeepCopyInto(&out.Auth) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitwardenSecretsManagerProvider. +func (in *BitwardenSecretsManagerProvider) DeepCopy() *BitwardenSecretsManagerProvider { + if in == nil { + return nil + } + out := new(BitwardenSecretsManagerProvider) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BitwardenSecretsManagerSecretRef) DeepCopyInto(out *BitwardenSecretsManagerSecretRef) { + *out = *in + in.Credentials.DeepCopyInto(&out.Credentials) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BitwardenSecretsManagerSecretRef. +func (in *BitwardenSecretsManagerSecretRef) DeepCopy() *BitwardenSecretsManagerSecretRef { + if in == nil { + return nil + } + out := new(BitwardenSecretsManagerSecretRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CAProvider) DeepCopyInto(out *CAProvider) { *out = *in @@ -2290,6 +2338,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) { *out = new(AkeylessProvider) (*in).DeepCopyInto(*out) } + if in.BitwardenSecretsManager != nil { + in, out := &in.BitwardenSecretsManager, &out.BitwardenSecretsManager + *out = new(BitwardenSecretsManagerProvider) + (*in).DeepCopyInto(*out) + } if in.Vault != nil { in, out := &in.Vault, &out.Vault *out = new(VaultProvider) diff --git a/config/crds/bases/external-secrets.io_clustersecretstores.yaml b/config/crds/bases/external-secrets.io_clustersecretstores.yaml index 58184ddae..5463ecaff 100644 --- a/config/crds/bases/external-secrets.io_clustersecretstores.yaml +++ b/config/crds/bases/external-secrets.io_clustersecretstores.yaml @@ -2294,6 +2294,68 @@ spec: required: - vaultUrl type: object + bitwardensecretsmanager: + description: BitwardenSecretsManager configures this store to + sync secrets using BitwardenSecretsManager provider + properties: + apiURL: + type: string + auth: + description: |- + Auth configures how secret-manager authenticates with a bitwarden machine account instance. + Make sure that the token being used has permissions on the given secret. + properties: + secretRef: + description: BitwardenSecretsManagerSecretRef contains + the credential ref to the bitwarden instance. + properties: + credentials: + description: AccessToken used for the bitwarden instance. + 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: + - credentials + type: object + required: + - secretRef + type: object + bitwardenServerSDKURL: + type: string + caBundle: + description: |- + Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack + can be performed. + type: string + identityURL: + type: string + organizationID: + description: OrganizationID determines which organization + this secret store manages. + type: string + projectID: + description: ProjectID determines which project this secret + store manages. + type: string + required: + - auth + - caBundle + - organizationID + - projectID + type: object chef: description: Chef configures this store to sync secrets with chef server diff --git a/config/crds/bases/external-secrets.io_secretstores.yaml b/config/crds/bases/external-secrets.io_secretstores.yaml index 74fb9bad0..eaba10eff 100644 --- a/config/crds/bases/external-secrets.io_secretstores.yaml +++ b/config/crds/bases/external-secrets.io_secretstores.yaml @@ -2294,6 +2294,68 @@ spec: required: - vaultUrl type: object + bitwardensecretsmanager: + description: BitwardenSecretsManager configures this store to + sync secrets using BitwardenSecretsManager provider + properties: + apiURL: + type: string + auth: + description: |- + Auth configures how secret-manager authenticates with a bitwarden machine account instance. + Make sure that the token being used has permissions on the given secret. + properties: + secretRef: + description: BitwardenSecretsManagerSecretRef contains + the credential ref to the bitwarden instance. + properties: + credentials: + description: AccessToken used for the bitwarden instance. + 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: + - credentials + type: object + required: + - secretRef + type: object + bitwardenServerSDKURL: + type: string + caBundle: + description: |- + Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack + can be performed. + type: string + identityURL: + type: string + organizationID: + description: OrganizationID determines which organization + this secret store manages. + type: string + projectID: + description: ProjectID determines which project this secret + store manages. + type: string + required: + - auth + - caBundle + - organizationID + - projectID + type: object chef: description: Chef configures this store to sync secrets with chef server diff --git a/deploy/charts/external-secrets/Chart.lock b/deploy/charts/external-secrets/Chart.lock new file mode 100644 index 000000000..1d198fe7c --- /dev/null +++ b/deploy/charts/external-secrets/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: bitwarden-sdk-server + repository: oci://ghcr.io/external-secrets/charts + version: v0.1.4 +digest: sha256:f60d5e4c6ad432fc7efdb0dad33774afaa88e02bd82eb9d5224372828f7d52be +generated: "2024-06-20T10:01:52.49841+02:00" diff --git a/deploy/charts/external-secrets/Chart.yaml b/deploy/charts/external-secrets/Chart.yaml index 0f452736f..17b32c53b 100644 --- a/deploy/charts/external-secrets/Chart.yaml +++ b/deploy/charts/external-secrets/Chart.yaml @@ -13,3 +13,9 @@ icon: https://raw.githubusercontent.com/external-secrets/external-secrets/main/a maintainers: - name: mcavoyk email: kellinmcavoy@gmail.com + +dependencies: + - name: bitwarden-sdk-server + version: v0.1.4 + repository: oci://ghcr.io/external-secrets/charts + condition: bitwarden-sdk-server.enabled diff --git a/deploy/charts/external-secrets/README.md b/deploy/charts/external-secrets/README.md index a9fb8ff21..743342012 100644 --- a/deploy/charts/external-secrets/README.md +++ b/deploy/charts/external-secrets/README.md @@ -35,6 +35,7 @@ The command removes all the Kubernetes components associated with the chart and | Key | Type | Default | Description | |-----|------|---------|-------------| | affinity | object | `{}` | | +| bitwarden-sdk-server.enabled | bool | `false` | | | certController.affinity | object | `{}` | | | certController.create | bool | `true` | Specifies whether a certificate controller deployment be created. | | certController.deploymentAnnotations | object | `{}` | Annotations to add to Deployment | @@ -108,7 +109,7 @@ The command removes all the Kubernetes components associated with the chart and | global.tolerations | list | `[]` | | | global.topologySpreadConstraints | list | `[]` | | | hostNetwork | bool | `false` | Run the controller on the host network | -| image.flavour | string | `""` | The flavour of tag you want to use There are different image flavours available, like distroless and ubi. Please see GitHub release notes for image tags for these flavors. By default the distroless image is used. | +| image.flavour | string | `""` | The flavour of tag you want to use There are different image flavours available, like distroless and ubi. Please see GitHub release notes for image tags for these flavors. By default, the distroless image is used. | | image.pullPolicy | string | `"IfNotPresent"` | | | image.repository | string | `"ghcr.io/external-secrets/external-secrets"` | | | image.tag | string | `""` | The image tag to use. The default is the chart appVersion. | diff --git a/deploy/charts/external-secrets/tests/__snapshot__/crds_test.yaml.snap b/deploy/charts/external-secrets/tests/__snapshot__/crds_test.yaml.snap index 1b789904c..6327f42a0 100644 --- a/deploy/charts/external-secrets/tests/__snapshot__/crds_test.yaml.snap +++ b/deploy/charts/external-secrets/tests/__snapshot__/crds_test.yaml.snap @@ -2158,6 +2158,63 @@ should match snapshot of default values: required: - vaultUrl type: object + bitwardensecretsmanager: + description: BitwardenSecretsManager configures this store to sync secrets using BitwardenSecretsManager provider + properties: + apiURL: + type: string + auth: + description: |- + Auth configures how secret-manager authenticates with a bitwarden machine account instance. + Make sure that the token being used has permissions on the given secret. + properties: + secretRef: + description: BitwardenSecretsManagerSecretRef contains the credential ref to the bitwarden instance. + properties: + credentials: + description: AccessToken used for the bitwarden instance. + 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: + - credentials + type: object + required: + - secretRef + type: object + bitwardenServerSDKURL: + type: string + caBundle: + description: |- + Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack + can be performed. + type: string + identityURL: + type: string + organizationID: + description: OrganizationID determines which organization this secret store manages. + type: string + projectID: + description: ProjectID determines which project this secret store manages. + type: string + required: + - auth + - caBundle + - organizationID + - projectID + type: object chef: description: Chef configures this store to sync secrets with chef server properties: diff --git a/deploy/charts/external-secrets/values.yaml b/deploy/charts/external-secrets/values.yaml index 17a0be9ca..19525ad8a 100644 --- a/deploy/charts/external-secrets/values.yaml +++ b/deploy/charts/external-secrets/values.yaml @@ -14,6 +14,9 @@ global: replicaCount: 1 +bitwarden-sdk-server: + enabled: false + # -- Specifies the amount of historic ReplicaSets k8s should keep (see https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#clean-up-policy) revisionHistoryLimit: 10 @@ -25,7 +28,7 @@ image: # -- The flavour of tag you want to use # There are different image flavours available, like distroless and ubi. # Please see GitHub release notes for image tags for these flavors. - # By default the distroless image is used. + # By default, the distroless image is used. flavour: "" # -- If set, install and upgrade CRDs through helm chart. diff --git a/deploy/crds/bundle.yaml b/deploy/crds/bundle.yaml index 29489d089..7b9073e72 100644 --- a/deploy/crds/bundle.yaml +++ b/deploy/crds/bundle.yaml @@ -2804,6 +2804,63 @@ spec: required: - vaultUrl type: object + bitwardensecretsmanager: + description: BitwardenSecretsManager configures this store to sync secrets using BitwardenSecretsManager provider + properties: + apiURL: + type: string + auth: + description: |- + Auth configures how secret-manager authenticates with a bitwarden machine account instance. + Make sure that the token being used has permissions on the given secret. + properties: + secretRef: + description: BitwardenSecretsManagerSecretRef contains the credential ref to the bitwarden instance. + properties: + credentials: + description: AccessToken used for the bitwarden instance. + 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: + - credentials + type: object + required: + - secretRef + type: object + bitwardenServerSDKURL: + type: string + caBundle: + description: |- + Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack + can be performed. + type: string + identityURL: + type: string + organizationID: + description: OrganizationID determines which organization this secret store manages. + type: string + projectID: + description: ProjectID determines which project this secret store manages. + type: string + required: + - auth + - caBundle + - organizationID + - projectID + type: object chef: description: Chef configures this store to sync secrets with chef server properties: @@ -8295,6 +8352,63 @@ spec: required: - vaultUrl type: object + bitwardensecretsmanager: + description: BitwardenSecretsManager configures this store to sync secrets using BitwardenSecretsManager provider + properties: + apiURL: + type: string + auth: + description: |- + Auth configures how secret-manager authenticates with a bitwarden machine account instance. + Make sure that the token being used has permissions on the given secret. + properties: + secretRef: + description: BitwardenSecretsManagerSecretRef contains the credential ref to the bitwarden instance. + properties: + credentials: + description: AccessToken used for the bitwarden instance. + 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: + - credentials + type: object + required: + - secretRef + type: object + bitwardenServerSDKURL: + type: string + caBundle: + description: |- + Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack + can be performed. + type: string + identityURL: + type: string + organizationID: + description: OrganizationID determines which organization this secret store manages. + type: string + projectID: + description: ProjectID determines which project this secret store manages. + type: string + required: + - auth + - caBundle + - organizationID + - projectID + type: object chef: description: Chef configures this store to sync secrets with chef server properties: diff --git a/docs/api/spec.md b/docs/api/spec.md index b4bf821a6..163e43741 100644 --- a/docs/api/spec.md +++ b/docs/api/spec.md @@ -1001,6 +1001,166 @@ string +

BitwardenSecretsManagerAuth +

+

+(Appears on: +BitwardenSecretsManagerProvider) +

+

+

BitwardenSecretsManagerAuth contains the ref to the secret that contains the machine account token.

+

+ + + + + + + + + + + + + +
FieldDescription
+secretRef
+ + +BitwardenSecretsManagerSecretRef + + +
+
+

BitwardenSecretsManagerProvider +

+

+(Appears on: +SecretStoreProvider) +

+

+

BitwardenSecretsManagerProvider configures a store to sync secrets with a Bitwarden Secrets Manager instance.

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+apiURL
+ +string + +
+
+identityURL
+ +string + +
+
+bitwardenServerSDKURL
+ +string + +
+
+caBundle
+ +string + +
+

Base64 encoded certificate for the bitwarden server sdk. The sdk MUST run with HTTPS to make sure no MITM attack +can be performed.

+
+organizationID
+ +string + +
+

OrganizationID determines which organization this secret store manages.

+
+projectID
+ +string + +
+

ProjectID determines which project this secret store manages.

+
+auth
+ + +BitwardenSecretsManagerAuth + + +
+

Auth configures how secret-manager authenticates with a bitwarden machine account instance. +Make sure that the token being used has permissions on the given secret.

+
+

BitwardenSecretsManagerSecretRef +

+

+(Appears on: +BitwardenSecretsManagerAuth) +

+

+

BitwardenSecretsManagerSecretRef contains the credential ref to the bitwarden instance.

+

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

AccessToken used for the bitwarden instance.

+

CAProvider

@@ -5975,6 +6135,20 @@ AkeylessProvider +bitwardensecretsmanager
+ + +BitwardenSecretsManagerProvider + + + + +(Optional) +

BitwardenSecretsManager configures this store to sync secrets using BitwardenSecretsManager provider

+ + + + vault
diff --git a/docs/introduction/stability-support.md b/docs/introduction/stability-support.md index 9388fdfd4..fce59a608 100644 --- a/docs/introduction/stability-support.md +++ b/docs/introduction/stability-support.md @@ -57,13 +57,14 @@ The following table describes the stability level of each provider and who's res | [Passbolt](https://external-secrets.io/latest/provider/passbolt) | alpha | | | [Infisical](https://external-secrets.io/latest/provider/infisical) | alpha | [@akhilmhdh](https://github.com/akhilmhdh) | | [Device42](https://external-secrets.io/latest/provider/device42) | alpha | | +| [Bitwarden Secrets Manager](https://external-secrets.io/latest/provider/bitwarden-secrets-manager) | alpha | | ## 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 | @@ -88,6 +89,7 @@ The following table show the support for features across different providers. | Passbolt | x | | | | x | | | | Infisical | x | | | x | x | | | | Device42 | | | | | x | | | +| Bitwarden Secrets Manager | x | | | | x | x | x | ## Support Policy diff --git a/docs/provider/bitwarden-secrets-manager.md b/docs/provider/bitwarden-secrets-manager.md new file mode 100644 index 000000000..44b481a74 --- /dev/null +++ b/docs/provider/bitwarden-secrets-manager.md @@ -0,0 +1,135 @@ +## Bitwarden Secrets Manager Provider + +This section describes how to set up the Bitwarden Secrets Manager provider for External Secrets Operator (ESO). + +### Prerequisites + +In order for the bitwarden provider to work, we need a second service. This service is the [Bitwarden SDK Server](https://github.com/external-secrets/bitwarden-sdk-server). +The Bitwarden SDK is Rust based and requires CGO enabled. In order to not restrict the capabilities of ESO, and the image +size ( the bitwarden Rust SDK libraries are over 150MB in size ) it has been decided to create a soft wrapper +around the SDK that runs as a separate service providing ESO with a light REST API to pull secrets through. + +#### Bitwarden SDK server + +The server itself can be installed together with ESO. The ESO Helm Chart packages this service as a dependency. +The Bitwarden SDK Server's full name is hardcoded to bitwarden-sdk-server. This is so that the exposed service URL +gets a determinable endpoint. + +In order to install the service install ESO with the following helm directive: + +``` +helm install external-secrets \ + external-secrets/external-secrets \ + -n external-secrets \ + --create-namespace \ + --set bitwarden-sdk-server.enabled=true +``` + +##### Certificate + +The Bitwarden SDK Server _NEEDS_ to run as an HTTPS service. That means that any installation that once to with Bitwarden +provider will need to generate a certificate. The best approach for that is to use cert-manager. It's easy to set up +and can generate a certificate that the store can use to connect with the server. + +For a sample set up look at the bitwarden sdk server's test setup. It contains a self-signed certificate issuer for +cert-manager. + +### External secret store + +With that out of the way, let's take a look at how a secret store would look like. + +```yaml +{% include 'bitwarden-secrets-manager-secret-store.yaml' %} +``` + +The api url and identity url are optional. The secret should contain the token for the Machine account for bitwarden. + +!!! note inline end +Make sure that the machine account has Read-Write access to the Project that the secrets are in. + +!!! note inline end +A secret store is organization/project dependent. Meaning a 1 store == 1 organization/project. This is so that we ensure +that no other project's secrets can be modified accidentally _or_ intentionally. + +### External Secrets + +There are two ways to fetch secrets from the provider. + +#### Find by UUID + +In order to fetch a secret by using its UUID simply provide that as remote key in the external secrets like this: + +```yaml +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: bitwarden +spec: + refreshInterval: 10s + secretStoreRef: + # This name must match the metadata.name in the `SecretStore` + name: bitwarden-secretsmanager + kind: SecretStore + data: + - secretKey: test + remoteRef: + key: "339062b8-a5a1-4303-bf1d-b1920146a622" +``` + +#### Find by Name + +To find a secret using its name, we need a bit more information. Mainly, these are the rules to find a secret: + +- if name is a UUID get the secret +- if name is NOT a UUID Property is mandatory that defines the projectID to look for +- if name + projectID + organizationID matches, we return that secret +- if more than one name exists for the same projectID within the same organization we error + +```yaml +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: bitwarden +spec: + refreshInterval: 10s + secretStoreRef: + # This name must match the metadata.name in the `SecretStore` + name: bitwarden-secretsmanager + kind: SecretStore + data: + - secretKey: test + remoteRef: + key: "secret-name" +``` + +### Push Secret + +Pushing a secret is also implemented. Pushing a secret requires even more restrictions because Bitwarden Secrets Manager +allows creating the same secret with the same key multiple times. In order to avoid overwriting, or potentially, returning +the wrong secret, we restrict push secret with the following rules: + +- name, projectID, organizationID and value AND NOTE equal, we won't push it again. +- name, projectID, organizationID and ONLY the value does not equal ( INCLUDING THE NOTE ) we update +- any of the above isn't true, we create the secret ( this means that it will create a secret in a separate project ) + +```yaml +apiVersion: external-secrets.io/v1alpha1 +kind: PushSecret +metadata: + name: pushsecret-bitwarden # Customisable +spec: + refreshInterval: 10s # Refresh interval for which push secret will reconcile + secretStoreRefs: # A list of secret stores to push secrets to + - name: bitwarden-secretsmanager + kind: SecretStore + selector: + secret: + name: my-secret # Source Kubernetes secret to be pushed + data: + - match: + secretKey: key # Source Kubernetes secret key to be pushed + remoteRef: + remoteKey: remote-key-name # Remote reference (where the secret is going to be pushed) + metadata: + note: "Note of the secret to add." +``` diff --git a/docs/snippets/bitwarden-secrets-manager-secret-store.yaml b/docs/snippets/bitwarden-secrets-manager-secret-store.yaml new file mode 100644 index 000000000..0b55da735 --- /dev/null +++ b/docs/snippets/bitwarden-secrets-manager-secret-store.yaml @@ -0,0 +1,18 @@ +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: bitwarden-secretsmanager +spec: + provider: + bitwardensecretsmanager: + apiURL: https://vault.bitwarden.com + identityURL: https://identity.bitwarden.com + auth: + secretRef: + credentials: + key: token + name: bitwarden-access-token + bitwardenServerSDKURL: https://bitwarden-sdk-server.default.svc.cluster.local:9998 + caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUQ5akNDQXQ2Z0F3SUJBZ0lRS08vM1J1dXR4YWdOeThCdUcyUTJYREFOQmdrcWhraUc5dzBCQVFzRkFEQkQKTVJ3d0dnWURWUVFLRXhObGVIUmxjbTVoYkMxelpXTnlaWFJ6TG1sdk1TTXdJUVlEVlFRREV4cGpaWEowTFcxaApibUZuWlhJdFltbDBkMkZ5WkdWdUxYUnNjekFlRncweU5EQTJNVGt4TXpJd01EUmFGdzB5TkRBNU1UY3hNekl3Ck1EUmFNRU14SERBYUJnTlZCQW9URTJWNGRHVnlibUZzTFhObFkzSmxkSE11YVc4eEl6QWhCZ05WQkFNVEdtTmwKY25RdGJXRnVZV2RsY2kxaWFYUjNZWEprWlc0dGRHeHpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QQpNSUlCQ2dLQ0FRRUExdlFxaTNCL0NVU01FaUx1b3NkTVdZV25QcWJmQ20xbnZsMWhoUWxjOW1ocDFnSmxDbndjCmE0MmxuTkx0TjNTUmdrZWFNYXppV1RyaDQ5SGdUeTNVQ2xoNDh5RXFvTmJDRUlaL2xxOHNoVzRMd2g0RTdNT08KOVJJMDY2a3JCYllYakZuam1ETjdJV1NLOVVwZjIrOUpLTi9PM3ZWTktLMGZhOERxRkppL3h3VUsyOGRNc05tZAo2NnkreW52TzRFRU51Wm9IRUFieWdrOTQ2cm9yNnNmUkxHZ3ZVYXg5cmd4dEh5TkZqcGkrbjhCUDRlQkRZeGI4CkVsQy93Q0Rza2NBNFF3TXphU3NFbDBwL3gwQm9nTS9nbWJWelNVemhBL2NGdXpMRVJmV0tuanJrbmpoenNFWncKRWlzUmZ6K3MyVnUvcm5YK3pabTBoWTFvSDZYY29mVkhOUUlEQVFBQm80SGxNSUhpTUE0R0ExVWREd0VCL3dRRQpBd0lDcERBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJTeUplK1lnUWZQbWFFOEZKSHowbzZ0CjQzeGh4VENCbndZRFZSMFJCSUdYTUlHVWdqOWxlSFJsY201aGJDMXpaV055WlhSekxXSnBkSGRoY21SbGJpMXoKWkdzdGMyVnlkbVZ5TG1SbFptRjFiSFF1YzNaakxtTnNkWE4wWlhJdWJHOWpZV3lDTG1KcGRIZGhjbVJsYmkxegpaR3N0YzJWeWRtVnlMbVJsWm1GMWJIUXVjM1pqTG1Oc2RYTjBaWEl1Ykc5allXeUNDV3h2WTJGc2FHOXpkSWNFCmZ3QUFBWWNRQUFBQUFBQUFBQUFBQUFBQUFBQUFBVEFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBdllYUW5ETEgKczc3N3NJN3cwN2NWMVIrTmZvbGRYblp5ejVtQVpDZkc4T2djZDRGdjRYV3lrRG94MzlkUWo0dTJnOWlVcUNwawp2QzJsbUR1UjNrS2kzbjgySTYyQ1BDN1JmZFd3M2hqaFJOV1NKbVBGeGF6NHkrbnMvMDZ3RFBlMmZwRXpPMXIzCmwxTFdZMHBySVlMME1EYTI1c3BUdlZPdWxyeWlnUnJRRGNEbS9hZ3krSEs4RHB3dWlTTEpsdFM0Q1JVa25mb3kKS00rL213VTd4RzNrSnN5ekR0T2dOZDhZeG1lRU44Q05WSk9JalltRk9OWTJrYU51S2ZnMU1aaXArcllPTEFqUgpJdUNxOFhSSTVST2gxOFJKdVlXcVZ6MUkwbXE4aVgwYlo2WG5WRjliZ0ViQ2d3bXZOWkZha3Z4RVhkWmR2N3VmCkYvRm9PTUFlNTY3L0RBPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ== + organizationID: 7c0d21ec-10d9-4972-bdf8-ec52df99cc86 + projectID: 9c713cd6-728c-437a-a783-252b0773a0bb diff --git a/go.mod b/go.mod index a42883ece..5052f7e05 100644 --- a/go.mod +++ b/go.mod @@ -90,6 +90,7 @@ require ( github.com/sethvargo/go-password v0.3.0 github.com/spf13/pflag v1.0.5 github.com/tidwall/sjson v1.2.5 + k8s.io/kube-openapi v0.0.0-20240620174524-b456828f718b sigs.k8s.io/yaml v1.4.0 software.sslmate.com/src/go-pkcs12 v0.4.0 ) @@ -195,7 +196,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - k8s.io/kube-openapi v0.0.0-20240620174524-b456828f718b // indirect lukechampine.com/frand v1.4.2 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect ) diff --git a/hack/api-docs/mkdocs.yml b/hack/api-docs/mkdocs.yml index 0dbb8015a..700701001 100644 --- a/hack/api-docs/mkdocs.yml +++ b/hack/api-docs/mkdocs.yml @@ -93,6 +93,7 @@ nav: - AWS Secrets Manager: provider/aws-secrets-manager.md - AWS Parameter Store: provider/aws-parameter-store.md - Azure Key Vault: provider/azure-key-vault.md + - Bitwarden Secrets Manager: provider/bitwarden-secrets-manager.md - Chef: provider/chef.md - CyberArk Conjur: provider/conjur.md - Device42: provider/device42.md diff --git a/pkg/provider/bitwarden/bitwarden_sdk.go b/pkg/provider/bitwarden/bitwarden_sdk.go new file mode 100644 index 000000000..12dd0bda3 --- /dev/null +++ b/pkg/provider/bitwarden/bitwarden_sdk.go @@ -0,0 +1,254 @@ +/* +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 bitwarden + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Defined Header Keys. +const ( + WardenHeaderAccessToken = "Warden-Access-Token" + WardenHeaderAPIURL = "Warden-Api-Url" + WardenHeaderIdentityURL = "Warden-Identity-Url" +) + +type SecretResponse struct { + CreationDate string `json:"creationDate"` + ID string `json:"id"` + Key string `json:"key"` + Note string `json:"note"` + OrganizationID string `json:"organizationId"` + ProjectID *string `json:"projectId,omitempty"` + RevisionDate string `json:"revisionDate"` + Value string `json:"value"` +} + +type SecretsDeleteResponse struct { + Data []SecretDeleteResponse `json:"data"` +} + +type SecretDeleteResponse struct { + Error *string `json:"error,omitempty"` + ID string `json:"id"` +} + +type SecretIdentifiersResponse struct { + Data []SecretIdentifierResponse `json:"data"` +} + +type SecretIdentifierResponse struct { + ID string `json:"id"` + Key string `json:"key"` + OrganizationID string `json:"organizationId"` +} + +type SecretCreateRequest struct { + Key string `json:"key"` + Note string `json:"note"` + // Organization where the secret will be created + OrganizationID string `json:"organizationId"` + // IDs of the projects that this secret will belong to + ProjectIDS []string `json:"projectIds,omitempty"` + Value string `json:"value"` +} + +type SecretPutRequest struct { + ID string `json:"id"` + Key string `json:"key"` + Note string `json:"note"` + // Organization where the secret will be created + OrganizationID string `json:"organizationId"` + // IDs of the projects that this secret will belong to + ProjectIDS []string `json:"projectIds,omitempty"` + Value string `json:"value"` +} + +// Client for the bitwarden SDK. +type Client interface { + GetSecret(ctx context.Context, id string) (*SecretResponse, error) + DeleteSecret(ctx context.Context, ids []string) (*SecretsDeleteResponse, error) + CreateSecret(ctx context.Context, secret SecretCreateRequest) (*SecretResponse, error) + UpdateSecret(ctx context.Context, secret SecretPutRequest) (*SecretResponse, error) + ListSecrets(ctx context.Context, organizationID string) (*SecretIdentifiersResponse, error) +} + +// SdkClient creates a client to talk to the bitwarden SDK server. +type SdkClient struct { + apiURL string + identityURL string + token string + bitwardenSdkServerURL string + + client *http.Client +} + +func NewSdkClient(apiURL, identityURL, bitwardenURL, token string, caBundle []byte) (*SdkClient, error) { + client, err := newHTTPSClient(caBundle) + if err != nil { + return nil, fmt.Errorf("error creating https client: %w", err) + } + + return &SdkClient{ + apiURL: apiURL, + identityURL: identityURL, + token: token, + client: client, + bitwardenSdkServerURL: bitwardenURL, + }, nil +} + +func (s *SdkClient) GetSecret(ctx context.Context, id string) (*SecretResponse, error) { + body := struct { + ID string `json:"id"` + }{ + ID: id, + } + secretResp := &SecretResponse{} + + if err := s.performHTTPRequestOperation(ctx, params{ + method: http.MethodGet, + url: s.bitwardenSdkServerURL + "/rest/api/1/secret", + body: body, + result: &secretResp, + }); err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + + return secretResp, nil +} + +func (s *SdkClient) DeleteSecret(ctx context.Context, ids []string) (*SecretsDeleteResponse, error) { + body := struct { + IDs []string `json:"ids"` + }{ + IDs: ids, + } + + secretResp := &SecretsDeleteResponse{} + if err := s.performHTTPRequestOperation(ctx, params{ + method: http.MethodDelete, + url: s.bitwardenSdkServerURL + "/rest/api/1/secret", + body: body, + result: &secretResp, + }); err != nil { + return nil, fmt.Errorf("failed to delete secret: %w", err) + } + + return secretResp, nil +} + +func (s *SdkClient) CreateSecret(ctx context.Context, createReq SecretCreateRequest) (*SecretResponse, error) { + secretResp := &SecretResponse{} + if err := s.performHTTPRequestOperation(ctx, params{ + method: http.MethodPost, + url: s.bitwardenSdkServerURL + "/rest/api/1/secret", + body: createReq, + result: &secretResp, + }); err != nil { + return nil, fmt.Errorf("failed to create secret: %w", err) + } + + return secretResp, nil +} + +func (s *SdkClient) UpdateSecret(ctx context.Context, putReq SecretPutRequest) (*SecretResponse, error) { + secretResp := &SecretResponse{} + if err := s.performHTTPRequestOperation(ctx, params{ + method: http.MethodPut, + url: s.bitwardenSdkServerURL + "/rest/api/1/secret", + body: putReq, + result: &secretResp, + }); err != nil { + return nil, fmt.Errorf("failed to update secret: %w", err) + } + + return secretResp, nil +} + +func (s *SdkClient) ListSecrets(ctx context.Context, organizationID string) (*SecretIdentifiersResponse, error) { + body := struct { + ID string `json:"organizationID"` + }{ + ID: organizationID, + } + secretResp := &SecretIdentifiersResponse{} + if err := s.performHTTPRequestOperation(ctx, params{ + method: http.MethodGet, + url: s.bitwardenSdkServerURL + "/rest/api/1/secrets", + body: body, + result: &secretResp, + }); err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + + return secretResp, nil +} + +func (s *SdkClient) constructSdkRequest(ctx context.Context, method, url string, body []byte) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to construct request: %w", err) + } + + req.Header.Set(WardenHeaderAccessToken, s.token) + req.Header.Set(WardenHeaderAPIURL, s.apiURL) + req.Header.Set(WardenHeaderIdentityURL, s.identityURL) + + return req, nil +} + +type params struct { + method string + url string + body any + result any +} + +func (s *SdkClient) performHTTPRequestOperation(ctx context.Context, params params) error { + data, err := json.Marshal(params.body) + if err != nil { + return fmt.Errorf("failed to marshal body: %w", err) + } + + req, err := s.constructSdkRequest(ctx, params.method, params.url, data) + if err != nil { + return fmt.Errorf("failed to construct request: %w", err) + } + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("failed to do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + content, _ := io.ReadAll(resp.Body) + + return fmt.Errorf("failed to perform http request, got response: %s with status code %d", string(content), resp.StatusCode) + } + + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(¶ms.result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + return nil +} diff --git a/pkg/provider/bitwarden/bitwarden_sdk_test.go b/pkg/provider/bitwarden/bitwarden_sdk_test.go new file mode 100644 index 000000000..0a6a268e0 --- /dev/null +++ b/pkg/provider/bitwarden/bitwarden_sdk_test.go @@ -0,0 +1,167 @@ +/* +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 bitwarden + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +// The rest of the tests much look the same, it would be nice if I could find a way +// to nicely unify the tests for all of them. + +func TestSdkClient_CreateSecret(t *testing.T) { + type fields struct { + apiURL func(c *httptest.Server) string + identityURL func(c *httptest.Server) string + bitwardenSdkServerURL func(c *httptest.Server) string + token string + testServer func(response any) *httptest.Server + response any + } + type args struct { + ctx context.Context + createReq SecretCreateRequest + } + tests := []struct { + name string + fields fields + args args + want *SecretResponse + wantErr bool + }{ + { + name: "create secret is successful", + fields: fields{ + testServer: func(response any) *httptest.Server { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, err := json.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + w.Write(data) + })) + + return testServer + }, + apiURL: func(c *httptest.Server) string { + return c.URL + }, + identityURL: func(c *httptest.Server) string { + return c.URL + }, + bitwardenSdkServerURL: func(c *httptest.Server) string { + return c.URL + }, + token: "token", + response: &SecretResponse{ + ID: "id", + Key: "key", + Note: "note", + OrganizationID: "orgID", + RevisionDate: "2024-04-04", + Value: "value", + }, + }, + args: args{ + ctx: context.Background(), + createReq: SecretCreateRequest{ + Key: "key", + Note: "note", + OrganizationID: "orgID", + ProjectIDS: []string{projectID}, + Value: "value", + }, + }, + want: &SecretResponse{ + ID: "id", + Key: "key", + Note: "note", + OrganizationID: "orgID", + RevisionDate: "2024-04-04", + Value: "value", + }, + }, + { + name: "create secret fails", + fields: fields{ + testServer: func(response any) *httptest.Server { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + + return testServer + }, + apiURL: func(c *httptest.Server) string { + return c.URL + }, + identityURL: func(c *httptest.Server) string { + return c.URL + }, + bitwardenSdkServerURL: func(c *httptest.Server) string { + return c.URL + }, + token: "token", + response: &SecretResponse{ + ID: "id", + Key: "key", + Note: "note", + OrganizationID: "orgID", + RevisionDate: "2024-04-04", + Value: "value", + }, + }, + args: args{ + ctx: context.Background(), + createReq: SecretCreateRequest{ + Key: "key", + Note: "note", + OrganizationID: "orgID", + ProjectIDS: []string{projectID}, + Value: "value", + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.fields.testServer(tt.fields.response) + defer server.Close() + s := &SdkClient{ + apiURL: tt.fields.apiURL(server), + identityURL: tt.fields.identityURL(server), + bitwardenSdkServerURL: tt.fields.bitwardenSdkServerURL(server), + token: tt.fields.token, + client: server.Client(), + } + got, err := s.CreateSecret(tt.args.ctx, tt.args.createReq) + if (err != nil) != tt.wantErr { + t.Errorf("CreateSecret() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("CreateSecret() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/provider/bitwarden/client.go b/pkg/provider/bitwarden/client.go new file mode 100644 index 000000000..80d58fc44 --- /dev/null +++ b/pkg/provider/bitwarden/client.go @@ -0,0 +1,301 @@ +/* +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 bitwarden + +import ( + "context" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/kube-openapi/pkg/validation/strfmt" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + "github.com/external-secrets/external-secrets/pkg/utils" +) + +var ( + errBadCertBundle = "caBundle failed to base64 decode: %w" +) + +const ( + // NoteMetadataKey defines the note for the pushed secret. + NoteMetadataKey = "note" +) + +// PushSecret will write a single secret into the provider. +// Note: We will refuse to overwrite ANY secrets, because we can never be completely +// sure if it's the same secret we are trying to push. We only have the Name and the value +// could be different. Therefore, we will always create a new secret. Except if, the value +// the key, the note, and organization ID all match. +// We only allow to push to a single project, because GET returns a single project ID +// the secret belongs to even though technically Create allows multiple projects. This is +// to ensure that we push to the same project always, and so we can determine reliably that +// we don't need to push again. +func (p *Provider) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1beta1.PushSecretData) error { + spec := p.store.GetSpec() + if spec == nil || spec.Provider == nil { + return fmt.Errorf("store does not have a provider") + } + + if data.GetSecretKey() == "" { + return fmt.Errorf("pushing the whole secret is not yet implemented") + } + + if data.GetRemoteKey() == "" { + return fmt.Errorf("remote key must be defined") + } + + value, ok := secret.Data[data.GetSecretKey()] + if !ok { + return fmt.Errorf("failed to find secret key in secret with key: %s", data.GetSecretKey()) + } + + note, err := utils.FetchValueFromMetadata(NoteMetadataKey, data.GetMetadata(), "") + if err != nil { + return fmt.Errorf("failed to fetch note from metadata: %w", err) + } + + // ListAll Secrets for an organization. If the key matches our key, we GetSecret that and do a compare. + remoteSecrets, err := p.bitwardenSdkClient.ListSecrets(ctx, spec.Provider.BitwardenSecretsManager.OrganizationID) + if err != nil { + return fmt.Errorf("failed to get all secrets: %w", err) + } + + for _, d := range remoteSecrets.Data { + if d.Key != data.GetRemoteKey() { + continue + } + + sec, err := p.bitwardenSdkClient.GetSecret(ctx, d.ID) + if err != nil { + return fmt.Errorf("failed to get secret: %w", err) + } + + // If all pushed data matches, we won't push this secret. + if sec.Key == data.GetRemoteKey() && + sec.Value == string(value) && + sec.Note == note && + sec.ProjectID != nil && + *sec.ProjectID == spec.Provider.BitwardenSecretsManager.ProjectID { + // we have a complete match, skip pushing. + return nil + } else if sec.Key == data.GetRemoteKey() && + sec.Value != string(value) && + sec.Note == note && + sec.ProjectID != nil && + *sec.ProjectID == spec.Provider.BitwardenSecretsManager.ProjectID { + // only the value is different, update the existing secret. + _, err = p.bitwardenSdkClient.UpdateSecret(ctx, SecretPutRequest{ + ID: sec.ID, + Key: data.GetRemoteKey(), + Note: note, + OrganizationID: spec.Provider.BitwardenSecretsManager.OrganizationID, + ProjectIDS: []string{spec.Provider.BitwardenSecretsManager.ProjectID}, + Value: string(value), + }) + + return err + } + } + + // no matching secret found, let's create it + _, err = p.bitwardenSdkClient.CreateSecret(ctx, SecretCreateRequest{ + Key: data.GetRemoteKey(), + Note: note, + OrganizationID: spec.Provider.BitwardenSecretsManager.OrganizationID, + ProjectIDS: []string{spec.Provider.BitwardenSecretsManager.ProjectID}, + Value: string(value), + }) + + return err +} + +// GetSecret returns a single secret from the provider. +func (p *Provider) GetSecret(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) { + if strfmt.IsUUID(ref.Key) { + resp, err := p.bitwardenSdkClient.GetSecret(ctx, ref.Key) + if err != nil { + return nil, fmt.Errorf("error getting secret: %w", err) + } + + return []byte(resp.Value), nil + } + + spec := p.store.GetSpec() + if spec == nil || spec.Provider == nil { + return nil, fmt.Errorf("store does not have a provider") + } + + secret, err := p.findSecretByRef(ctx, ref.Key, spec.Provider.BitwardenSecretsManager.ProjectID) + if err != nil { + return nil, fmt.Errorf("error getting secret: %w", err) + } + + // we found our secret, return the value for it + return []byte(secret.Value), nil +} + +func (p *Provider) DeleteSecret(ctx context.Context, ref esv1beta1.PushSecretRemoteRef) error { + if strfmt.IsUUID(ref.GetRemoteKey()) { + return p.deleteSecret(ctx, ref.GetRemoteKey()) + } + + spec := p.store.GetSpec() + if spec == nil || spec.Provider == nil { + return fmt.Errorf("store does not have a provider") + } + + secret, err := p.findSecretByRef(ctx, ref.GetRemoteKey(), spec.Provider.BitwardenSecretsManager.ProjectID) + if err != nil { + return fmt.Errorf("error getting secret: %w", err) + } + + return p.deleteSecret(ctx, secret.ID) +} + +func (p *Provider) deleteSecret(ctx context.Context, id string) error { + resp, err := p.bitwardenSdkClient.DeleteSecret(ctx, []string{id}) + if err != nil { + return fmt.Errorf("error deleting secret: %w", err) + } + + var errs error + for _, data := range resp.Data { + if data.Error != nil { + errs = errors.Join(errs, fmt.Errorf("error deleting secret with id %s: %s", data.ID, *data.Error)) + } + } + + if errs != nil { + return fmt.Errorf("there were one or more errors deleting secrets: %w", errs) + } + return nil +} + +func (p *Provider) SecretExists(ctx context.Context, ref esv1beta1.PushSecretRemoteRef) (bool, error) { + if strfmt.IsUUID(ref.GetRemoteKey()) { + _, err := p.bitwardenSdkClient.GetSecret(ctx, ref.GetRemoteKey()) + if err != nil { + return false, fmt.Errorf("error getting secret: %w", err) + } + + return true, nil + } + + spec := p.store.GetSpec() + if spec == nil || spec.Provider == nil { + return false, fmt.Errorf("store does not have a provider") + } + + if _, err := p.findSecretByRef(ctx, ref.GetRemoteKey(), spec.Provider.BitwardenSecretsManager.ProjectID); err != nil { + return false, fmt.Errorf("error getting secret: %w", err) + } + + return true, nil +} + +// GetSecretMap returns multiple k/v pairs from the provider. +func (p *Provider) GetSecretMap(ctx context.Context, ref esv1beta1.ExternalSecretDataRemoteRef) (map[string][]byte, error) { + return nil, fmt.Errorf("GetSecretMap() not implemented") +} + +// GetAllSecrets gets multiple secrets from the provider and loads into a kubernetes secret. +// First load all secrets from secretStore path configuration +// Then, gets secrets from a matching name or matching custom_metadata. +func (p *Provider) GetAllSecrets(ctx context.Context, _ esv1beta1.ExternalSecretFind) (map[string][]byte, error) { + spec := p.store.GetSpec() + if spec == nil { + return nil, fmt.Errorf("store does not have a provider") + } + + secrets, err := p.bitwardenSdkClient.ListSecrets(ctx, spec.Provider.BitwardenSecretsManager.OrganizationID) + if err != nil { + return nil, fmt.Errorf("failed to get all secrets: %w", err) + } + + result := map[string][]byte{} + for _, d := range secrets.Data { + sec, err := p.bitwardenSdkClient.GetSecret(ctx, d.ID) + if err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + + result[d.ID] = []byte(sec.Value) + } + + return result, nil +} + +// Validate validates the provider. +func (p *Provider) Validate() (esv1beta1.ValidationResult, error) { + return esv1beta1.ValidationResultReady, nil +} + +// Close closes the provider. +func (p *Provider) Close(_ context.Context) error { + return nil +} + +// getCABundle try retrieve the CA bundle from the provider CABundle. +func (p *Provider) getCABundle(provider *esv1beta1.BitwardenSecretsManagerProvider) ([]byte, error) { + certBytes, decodeErr := utils.Decode(esv1beta1.ExternalSecretDecodeBase64, []byte(provider.CABundle)) + if decodeErr != nil { + return nil, fmt.Errorf(errBadCertBundle, decodeErr) + } + + return certBytes, nil +} + +func (p *Provider) findSecretByRef(ctx context.Context, key, projectID string) (*SecretResponse, error) { + spec := p.store.GetSpec() + if spec == nil || spec.Provider == nil { + return nil, fmt.Errorf("store does not have a provider") + } + + // ListAll Secrets for an organization. If the key matches our key, we GetSecret that and do a compare. + secrets, err := p.bitwardenSdkClient.ListSecrets(ctx, spec.Provider.BitwardenSecretsManager.OrganizationID) + if err != nil { + return nil, fmt.Errorf("failed to get all secrets: %w", err) + } + + var remoteSecret *SecretResponse + for _, d := range secrets.Data { + if d.Key != key { + continue + } + + sec, err := p.bitwardenSdkClient.GetSecret(ctx, d.ID) + if err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + + if sec.ProjectID != nil && *sec.ProjectID == projectID { + if remoteSecret != nil { + return nil, fmt.Errorf("more than one secret found for project %s with key %s", projectID, key) + } + + // We don't break here because we WANT TO MAKE SURE that there is ONLY ONE + // such secret. + remoteSecret = sec + } + } + + if remoteSecret == nil { + return nil, fmt.Errorf("no secret found for project id %s and name %s", projectID, key) + } + + return remoteSecret, nil +} diff --git a/pkg/provider/bitwarden/client_test.go b/pkg/provider/bitwarden/client_test.go new file mode 100644 index 000000000..1630ddeb3 --- /dev/null +++ b/pkg/provider/bitwarden/client_test.go @@ -0,0 +1,825 @@ +/* +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 bitwarden + +import ( + "context" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1" + "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" +) + +var projectID = "e8fc8f9c-2208-446e-9e89-9bc358f39b47" + +func TestProviderDeleteSecret(t *testing.T) { + type fields struct { + kube client.Client + namespace string + store v1beta1.GenericStore + mock func(c *FakeClient) + assertMock func(t *testing.T, c *FakeClient) + } + type args struct { + ctx context.Context + ref v1beta1.PushSecretRemoteRef + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "delete secret is successfully with UUID", + fields: fields{ + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.DeleteSecretReturnsOnCallN(0, &SecretsDeleteResponse{}) + }, + assertMock: func(t *testing.T, c *FakeClient) { + assert.Equal(t, 1, c.deleteSecretCalledN) + }, + }, + args: args{ + ctx: context.TODO(), + ref: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "d8f29773-3019-4973-9bbc-66327d077fe2", + }, + }, + }, + { + name: "delete secret by name", + fields: fields{ + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + }, + }, + }) + + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "key", + Note: "note", + OrganizationID: "org", + Value: "value", + ProjectID: &projectID, + }) + c.DeleteSecretReturnsOnCallN(0, &SecretsDeleteResponse{}) + }, + assertMock: func(t *testing.T, c *FakeClient) { + assert.Equal(t, 1, c.deleteSecretCalledN) + }, + }, + args: args{ + ctx: context.TODO(), + ref: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "d8f29773-3019-4973-9bbc-66327d077fe2", + }, + }, + }, + { + name: "delete secret by name will not delete if something doesn't match", + fields: fields{ + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + }, + }, + }) + + projectID := "another-project" + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + Note: "note", + OrganizationID: "orgid", + Value: "value", + ProjectID: &projectID, + }) + }, + assertMock: func(t *testing.T, c *FakeClient) { + assert.Equal(t, 0, c.deleteSecretCalledN) + }, + }, + wantErr: true, // no secret found + args: args{ + ctx: context.TODO(), + ref: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "this-is-a-name", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &FakeClient{} + tt.fields.mock(fakeClient) + + p := &Provider{ + kube: tt.fields.kube, + namespace: tt.fields.namespace, + store: tt.fields.store, + bitwardenSdkClient: fakeClient, + } + if err := p.DeleteSecret(tt.args.ctx, tt.args.ref); (err != nil) != tt.wantErr { + t.Errorf("DeleteSecret() error = %v, wantErr %v", err, tt.wantErr) + } + + tt.fields.assertMock(t, fakeClient) + }) + } +} + +func TestProviderGetAllSecrets(t *testing.T) { + type fields struct { + kube client.Client + namespace string + store v1beta1.GenericStore + mock func(c *FakeClient) + } + type args struct { + ctx context.Context + ref v1beta1.ExternalSecretFind + } + tests := []struct { + name string + fields fields + args args + want map[string][]byte + wantErr bool + }{ + { + name: "get all secrets", + fields: fields{ + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "key1", + OrganizationID: "orgid", + }, + { + ID: "7c0d21ec-10d9-4972-bdf8-ec52df99cc86", + Key: "key2", + OrganizationID: "orgid", + }, + }, + }) + + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "key1", + Value: "value1", + }) + c.GetSecretReturnsOnCallN(1, &SecretResponse{ + ID: "7c0d21ec-10d9-4972-bdf8-ec52df99cc86", + Key: "key2", + Value: "value2", + }) + }, + }, + args: args{ + ctx: context.TODO(), + ref: v1beta1.ExternalSecretFind{}, + }, + want: map[string][]byte{ + "d8f29773-3019-4973-9bbc-66327d077fe2": []byte("value1"), + "7c0d21ec-10d9-4972-bdf8-ec52df99cc86": []byte("value2"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &FakeClient{} + tt.fields.mock(fakeClient) + + p := &Provider{ + kube: tt.fields.kube, + namespace: tt.fields.namespace, + store: tt.fields.store, + bitwardenSdkClient: fakeClient, + } + got, err := p.GetAllSecrets(tt.args.ctx, tt.args.ref) + if (err != nil) != tt.wantErr { + t.Errorf("GetAllSecrets() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetAllSecrets() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProviderGetSecret(t *testing.T) { + type fields struct { + kube func() client.Client + namespace string + store v1beta1.GenericStore + mock func(c *FakeClient) + } + type args struct { + ctx context.Context + ref v1beta1.ExternalSecretDataRemoteRef + } + tests := []struct { + name string + fields fields + args args + want []byte + wantErr bool + }{ + { + name: "get secret with UUID", + fields: fields{ + kube: func() client.Client { + return fake.NewFakeClient() + }, + namespace: "default", + store: &v1beta1.SecretStore{}, + mock: func(c *FakeClient) { + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "id", + Key: "key", + Note: "note", + OrganizationID: "org", + Value: "value", + }) + }, + }, + args: args{ + ctx: context.Background(), + ref: v1beta1.ExternalSecretDataRemoteRef{ + Key: "d8f29773-3019-4973-9bbc-66327d077fe2", + }, + }, + want: []byte("value"), + }, + { + name: "get secret by name", + fields: fields{ + kube: func() client.Client { + return fake.NewFakeClient() + }, + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + }, + }, + }) + + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "key", + Note: "note", + OrganizationID: "org", + Value: "value", + ProjectID: &projectID, + }) + }, + }, + args: args{ + ctx: context.Background(), + ref: v1beta1.ExternalSecretDataRemoteRef{ + Key: "this-is-a-name", + }, + }, + want: []byte("value"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &FakeClient{} + tt.fields.mock(fakeClient) + + p := &Provider{ + kube: tt.fields.kube(), + namespace: tt.fields.namespace, + store: tt.fields.store, + bitwardenSdkClient: fakeClient, + } + got, err := p.GetSecret(tt.args.ctx, tt.args.ref) + if (err != nil) != tt.wantErr { + t.Errorf("GetSecret() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetSecret() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProviderPushSecret(t *testing.T) { + type fields struct { + kube func() client.Client + namespace string + store v1beta1.GenericStore + mock func(c *FakeClient) + assertMock func(t *testing.T, c *FakeClient) + } + type args struct { + ctx context.Context + secret *corev1.Secret + data v1beta1.PushSecretData + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "push secret is successful for a none existent remote secret", + args: args{ + ctx: context.Background(), + secret: &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("value"), + }, + }, + data: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + SecretKey: "key", + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "this-is-a-name", + }, + }, + }, + }, + fields: fields{ + kube: func() client.Client { + return fake.NewFakeClient() + }, + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + }, + }, + }) + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "no-match", // if this is this-is-a-name it would match + Note: "", + OrganizationID: "orgid", + Value: "value", + ProjectID: &projectID, + }) + c.CreateSecretReturnsOnCallN(0, &SecretResponse{}) + }, + assertMock: func(t *testing.T, c *FakeClient) { + cargs := c.createSecretCallArguments[0] + assert.Equal(t, cargs, SecretCreateRequest{ + Key: "this-is-a-name", + Note: "", + OrganizationID: "orgid", + ProjectIDS: []string{projectID}, + Value: "value", + }) + }, + }, + }, + { + name: "push secret is successful for a existing remote secret but only the value differs will call update", + args: args{ + ctx: context.Background(), + secret: &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("new-value"), + }, + }, + data: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + SecretKey: "key", + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "this-is-a-name", + }, + }, + }, + }, + fields: fields{ + kube: func() client.Client { + return fake.NewFakeClient() + }, + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + }, + }, + }) + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + Note: "", + OrganizationID: "orgid", + Value: "value", + ProjectID: &projectID, + }) + c.UpdateSecretReturnsOnCallN(0, &SecretResponse{}) + }, + assertMock: func(t *testing.T, c *FakeClient) { + pargs := c.updateSecretCallArguments[0] + assert.Equal(t, pargs, SecretPutRequest{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + Note: "", + OrganizationID: "orgid", + ProjectIDS: []string{projectID}, + Value: "new-value", + }) + }, + }, + }, + { + name: "push secret will not push if the same secret already exists", + args: args{ + ctx: context.Background(), + secret: &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("value"), + }, + }, + data: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + SecretKey: "key", + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "this-is-a-name", + }, + }, + }, + }, + fields: fields{ + kube: func() client.Client { + return fake.NewFakeClient() + }, + namespace: "default", + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + }, + }, + }) + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "this-is-a-name", + OrganizationID: "orgid", + Value: "value", + ProjectID: &projectID, + }) + c.UpdateSecretReturnsOnCallN(0, &SecretResponse{}) + }, + assertMock: func(t *testing.T, c *FakeClient) { + assert.Equal(t, 0, c.createSecretCalledN) + assert.Equal(t, 0, c.updateSecretCalledN) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &FakeClient{} + tt.fields.mock(fakeClient) + + p := &Provider{ + kube: tt.fields.kube(), + namespace: tt.fields.namespace, + store: tt.fields.store, + bitwardenSdkClient: fakeClient, + } + + if err := p.PushSecret(tt.args.ctx, tt.args.secret, tt.args.data); (err != nil) != tt.wantErr { + t.Errorf("PushSecret() error = %v, wantErr %v", err, tt.wantErr) + } + + tt.fields.assertMock(t, fakeClient) + }) + } +} + +func TestProviderSecretExists(t *testing.T) { + type fields struct { + kube client.Client + namespace string + store v1beta1.GenericStore + mock func(c *FakeClient) + assertMock func(t *testing.T, c *FakeClient) + } + type args struct { + ctx context.Context + ref v1alpha1.PushSecretData + } + tests := []struct { + name string + fields fields + args args + want bool + wantErr bool + }{ + { + name: "secret exists", + fields: fields{ + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.GetSecretReturnsOnCallN(0, &SecretResponse{}) + }, + assertMock: func(t *testing.T, c *FakeClient) { + assert.Equal(t, 0, c.listSecretsCalledN) + }, + }, + args: args{ + ctx: nil, + ref: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "d8f29773-3019-4973-9bbc-66327d077fe2", + }, + }, + }, + }, + want: true, + }, + { + name: "secret exists by name", + fields: fields{ + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "name", + OrganizationID: "orgid", + }, + }, + }) + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "name", + OrganizationID: "orgid", + Value: "value", + ProjectID: &projectID, + }) + }, + }, + args: args{ + ctx: nil, + ref: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "name", + }, + }, + }, + }, + want: true, + }, + { + name: "secret not found by name", + fields: fields{ + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + c.ListSecretReturnsOnCallN(0, &SecretIdentifiersResponse{ + Data: []SecretIdentifierResponse{ + { + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "name", + OrganizationID: "orgid", + }, + }, + }) + projectIDDifferent := "different-project" + c.GetSecretReturnsOnCallN(0, &SecretResponse{ + ID: "d8f29773-3019-4973-9bbc-66327d077fe2", + Key: "name", + OrganizationID: "orgid", + Value: "value", + ProjectID: &projectIDDifferent, + }) + }, + }, + args: args{ + ctx: nil, + ref: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "name", + }, + }, + }, + }, + want: false, + wantErr: true, // secret not found + }, + { + name: "invalid name format should error", + fields: fields{ + store: &v1beta1.SecretStore{ + Spec: v1beta1.SecretStoreSpec{ + Provider: &v1beta1.SecretStoreProvider{ + BitwardenSecretsManager: &v1beta1.BitwardenSecretsManagerProvider{ + OrganizationID: "orgid", + ProjectID: projectID, + }, + }, + }, + }, + mock: func(c *FakeClient) { + }, + assertMock: func(t *testing.T, c *FakeClient) { + assert.Equal(t, 0, c.listSecretsCalledN) + }, + }, + args: args{ + ctx: nil, + ref: v1alpha1.PushSecretData{ + Match: v1alpha1.PushSecretMatch{ + RemoteRef: v1alpha1.PushSecretRemoteRef{ + RemoteKey: "name", + }, + }, + }, + }, + want: false, + wantErr: true, // invalid remote key format + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := &FakeClient{} + tt.fields.mock(fakeClient) + + p := &Provider{ + kube: tt.fields.kube, + namespace: tt.fields.namespace, + store: tt.fields.store, + bitwardenSdkClient: fakeClient, + } + got, err := p.SecretExists(tt.args.ctx, tt.args.ref) + if (err != nil) != tt.wantErr { + t.Errorf("SecretExists() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("SecretExists() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/provider/bitwarden/fake_client.go b/pkg/provider/bitwarden/fake_client.go new file mode 100644 index 000000000..7c8bec2e1 --- /dev/null +++ b/pkg/provider/bitwarden/fake_client.go @@ -0,0 +1,137 @@ +/* +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 bitwarden + +import ( + "context" + "fmt" +) + +type FakeClient struct { + getSecretCallArguments []string + getSecretReturnsOnCall map[int]*SecretResponse + getSecretCalledN int + + deleteSecretCallArguments [][]string + deleteSecretReturnsOnCall map[int]*SecretsDeleteResponse + deleteSecretCalledN int + + createSecretCallArguments []SecretCreateRequest + createSecretReturnsOnCall map[int]*SecretResponse + createSecretCalledN int + + updateSecretCallArguments []SecretPutRequest + updateSecretReturnsOnCall map[int]*SecretResponse + updateSecretCalledN int + + listSecretsCallArguments []string + listSecretsReturnsOnCall map[int]*SecretIdentifiersResponse + listSecretsCalledN int +} + +func (c *FakeClient) GetSecretReturnsOnCallN(call int, ret *SecretResponse) { + if c.getSecretReturnsOnCall == nil { + c.getSecretReturnsOnCall = make(map[int]*SecretResponse) + } + + c.getSecretReturnsOnCall[call] = ret +} + +func (c *FakeClient) GetSecret(ctx context.Context, id string) (*SecretResponse, error) { + ret, ok := c.getSecretReturnsOnCall[c.getSecretCalledN] + if !ok { + return nil, fmt.Errorf("get secret no canned responses set for call %d", c.getSecretCalledN) + } + + c.getSecretCallArguments = append(c.getSecretCallArguments, id) + c.getSecretCalledN++ + return ret, nil +} + +func (c *FakeClient) DeleteSecretReturnsOnCallN(call int, ret *SecretsDeleteResponse) { + if c.deleteSecretReturnsOnCall == nil { + c.deleteSecretReturnsOnCall = make(map[int]*SecretsDeleteResponse) + } + + c.deleteSecretReturnsOnCall[call] = ret +} + +func (c *FakeClient) DeleteSecret(ctx context.Context, ids []string) (*SecretsDeleteResponse, error) { + ret, ok := c.deleteSecretReturnsOnCall[c.deleteSecretCalledN] + if !ok { + return nil, fmt.Errorf("delete secret no canned responses set for call %d", c.deleteSecretCalledN) + } + + c.deleteSecretCalledN++ + c.deleteSecretCallArguments = append(c.deleteSecretCallArguments, ids) + return ret, nil +} + +func (c *FakeClient) CreateSecretReturnsOnCallN(call int, ret *SecretResponse) { + if c.createSecretReturnsOnCall == nil { + c.createSecretReturnsOnCall = make(map[int]*SecretResponse) + } + + c.createSecretReturnsOnCall[call] = ret +} + +func (c *FakeClient) CreateSecret(ctx context.Context, secret SecretCreateRequest) (*SecretResponse, error) { + ret, ok := c.createSecretReturnsOnCall[c.createSecretCalledN] + if !ok { + return nil, fmt.Errorf("create secret no canned responses set for call %d", c.createSecretCalledN) + } + + c.createSecretCalledN++ + c.createSecretCallArguments = append(c.createSecretCallArguments, secret) + return ret, nil +} + +func (c *FakeClient) UpdateSecretReturnsOnCallN(call int, ret *SecretResponse) { + if c.updateSecretReturnsOnCall == nil { + c.updateSecretReturnsOnCall = make(map[int]*SecretResponse) + } + + c.updateSecretReturnsOnCall[call] = ret +} + +func (c *FakeClient) UpdateSecret(ctx context.Context, secret SecretPutRequest) (*SecretResponse, error) { + ret, ok := c.updateSecretReturnsOnCall[c.updateSecretCalledN] + if !ok { + return nil, fmt.Errorf("secret update no canned responses set for call %d", c.updateSecretCalledN) + } + + c.updateSecretCalledN++ + c.updateSecretCallArguments = append(c.updateSecretCallArguments, secret) + return ret, nil +} + +func (c *FakeClient) ListSecretReturnsOnCallN(call int, ret *SecretIdentifiersResponse) { + if c.listSecretsReturnsOnCall == nil { + c.listSecretsReturnsOnCall = make(map[int]*SecretIdentifiersResponse) + } + + c.listSecretsReturnsOnCall[call] = ret +} + +func (c *FakeClient) ListSecrets(ctx context.Context, organizationID string) (*SecretIdentifiersResponse, error) { + ret, ok := c.listSecretsReturnsOnCall[c.listSecretsCalledN] + if !ok { + return nil, fmt.Errorf("secret list no canned responses set for call %d", c.listSecretsCalledN) + } + + c.listSecretsCalledN++ + c.listSecretsCallArguments = append(c.listSecretsCallArguments, organizationID) + return ret, nil +} diff --git a/pkg/provider/bitwarden/provider.go b/pkg/provider/bitwarden/provider.go new file mode 100644 index 000000000..cdc8d0d5f --- /dev/null +++ b/pkg/provider/bitwarden/provider.go @@ -0,0 +1,107 @@ +/* +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 bitwarden + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "time" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1" + "github.com/external-secrets/external-secrets/pkg/utils/resolvers" +) + +type Provider struct { + kube client.Client + namespace string + store esv1beta1.GenericStore + bitwardenSdkClient Client +} + +func init() { + esv1beta1.Register(&Provider{}, &esv1beta1.SecretStoreProvider{BitwardenSecretsManager: &esv1beta1.BitwardenSecretsManagerProvider{}}) +} + +// NewClient creates a new Bitwarden Secret Manager client. +func (p *Provider) NewClient(ctx context.Context, store esv1beta1.GenericStore, kube client.Client, namespace string) (esv1beta1.SecretsClient, error) { + storeSpec := store.GetSpec() + if storeSpec == nil || storeSpec.Provider == nil || storeSpec.Provider.BitwardenSecretsManager == nil { + return nil, fmt.Errorf("no store type or wrong store type") + } + + token, err := resolvers.SecretKeyRef( + ctx, + kube, + store.GetKind(), + namespace, + &storeSpec.Provider.BitwardenSecretsManager.Auth.SecretRef.Credentials, + ) + if err != nil { + return nil, fmt.Errorf("could not resolve auth credentials: %w", err) + } + + bundle, err := p.getCABundle(storeSpec.Provider.BitwardenSecretsManager) + if err != nil { + return nil, fmt.Errorf("could not resolve caBundle: %w", err) + } + + sdkClient, err := NewSdkClient( + storeSpec.Provider.BitwardenSecretsManager.APIURL, + storeSpec.Provider.BitwardenSecretsManager.IdentityURL, + storeSpec.Provider.BitwardenSecretsManager.BitwardenServerSDKURL, + token, + bundle, + ) + if err != nil { + return nil, fmt.Errorf("could not create SdkClient: %w", err) + } + + return &Provider{ + kube: kube, + namespace: namespace, + store: store, + bitwardenSdkClient: sdkClient, + }, nil +} + +// Capabilities returns the provider Capabilities (Read, Write, ReadWrite). +func (p *Provider) Capabilities() esv1beta1.SecretStoreCapabilities { + return esv1beta1.SecretStoreReadWrite +} + +// ValidateStore validates the store. +func (p *Provider) ValidateStore(_ esv1beta1.GenericStore) (admission.Warnings, error) { + return nil, nil +} + +// newHTTPSClient creates a new HTTPS client with the given cert. +func newHTTPSClient(cert []byte) (*http.Client, error) { + pool := x509.NewCertPool() + ok := pool.AppendCertsFromPEM(cert) + if !ok { + return nil, fmt.Errorf("can't append Conjur SSL cert") + } + tr := &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + } + + return &http.Client{Transport: tr, Timeout: time.Second * 10}, nil +} diff --git a/pkg/provider/register/register.go b/pkg/provider/register/register.go index 87ce099ce..976a825cb 100644 --- a/pkg/provider/register/register.go +++ b/pkg/provider/register/register.go @@ -21,6 +21,7 @@ import ( _ "github.com/external-secrets/external-secrets/pkg/provider/alibaba" _ "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/bitwarden" _ "github.com/external-secrets/external-secrets/pkg/provider/chef" _ "github.com/external-secrets/external-secrets/pkg/provider/conjur" _ "github.com/external-secrets/external-secrets/pkg/provider/delinea"