| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- /*
- Copyright © The ESO Authors
- 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
- https://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 webhook
- import (
- "net/http"
- "time"
- // nolint
- . "github.com/onsi/ginkgo/v2"
- // nolint
- . "github.com/onsi/gomega"
- corev1 "k8s.io/api/core/v1"
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "sigs.k8s.io/controller-runtime/pkg/client"
- "github.com/external-secrets/external-secrets-e2e/framework"
- "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
- esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
- esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
- esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
- )
- var _ = Describe("[webhook]", Label("webhook"), func() {
- f := framework.New("eso-webhook")
- prov := NewProvider(f)
- // Only the shared entries the provider can actually satisfy are wired up.
- // GetSecret ignores remoteRef.property entirely (it uses the ref for
- // templating and then applies result.jsonPath), so every property-based
- // entry in cases/common would fail here for a reason that is a documented
- // provider limitation rather than a regression.
- DescribeTable("sync secrets",
- framework.TableFuncWithExternalSecret(f, prov),
- Entry(common.SimpleDataSync(f)),
- Entry(common.SyncWithoutTargetName(f)),
- Entry(common.JSONDataFromSync(f)),
- Entry(common.JSONDataFromRewrite(f)),
- Entry(common.SSHKeySync(f)),
- Entry(common.DeletionPolicyDelete(f)),
- )
- It("templates the url, headers and spec.secrets values", func() {
- const key = "templating"
- prov.CreateSecret(key, framework.SecretEntry{Value: "templated-value"})
- createExternalSecret(f, "e2e-tpl", key)
- // Type must be set: equalSecrets compares it, and ESO leaves the target
- // to be defaulted to Opaque unless spec.target.template.type says
- // otherwise, so an empty Type here would never converge.
- _, err := f.WaitForSecretValue(f.Namespace.Name, framework.TargetSecretName,
- &corev1.Secret{
- Type: corev1.SecretTypeOpaque,
- Data: map[string][]byte{"value": []byte("templated-value")},
- })
- Expect(err).ToNot(HaveOccurred())
- // The request the controller actually sent proves the templating, which
- // a green sync alone would not: the url carried the remote key, and the
- // headers resolved both remoteRef and the .<name>.<keyInSecret> form
- // that spec.secrets values are addressed by.
- requests := prov.backend.requestsFor(key)
- Expect(requests).ToNot(BeEmpty(), "backend recorded no request for %q", key)
- got := requests[len(requests)-1]
- Expect(got.Method).To(Equal(http.MethodGet))
- Expect(got.Path).To(HaveSuffix(kvPath + key))
- Expect(got.Header.Get("Authorization")).To(Equal("Bearer " + authSecretValue))
- Expect(got.Header.Get("X-Remote-Key")).To(Equal(key))
- })
- It("refuses a store secret without the external-secrets.io/type label", func() {
- const key = "unlabelled"
- prov.CreateSecret(key, framework.SecretEntry{Value: "never-read"})
- By("pointing the store at a Secret that carries no type label")
- prov.CreateAuthSecret("webhook-e2e-unlabelled", false)
- replaceStoreSecret(f, prov, "webhook-e2e-unlabelled")
- createExternalSecret(f, "e2e-unlabelled", key)
- expectNotReadyBecause(f, "e2e-unlabelled", "external-secrets.io/type")
- expectNoTargetSecret(f)
- })
- It("refuses ntlm secret refs without the external-secrets.io/type label", func() {
- const key = "ntlm"
- prov.CreateSecret(key, framework.SecretEntry{Value: "never-read"})
- By("adding ntlm auth whose Secrets carry no type label")
- prov.CreateAuthSecret("webhook-e2e-ntlm", false)
- store := &esv1.SecretStore{}
- Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
- Namespace: f.Namespace.Name, Name: f.Namespace.Name,
- }, store)).To(Succeed())
- base := store.DeepCopy()
- ref := esmeta.SecretKeySelector{Name: "webhook-e2e-ntlm", Key: authSecretKey}
- store.Spec.Provider.Webhook.Auth = &esv1.AuthorizationProtocol{
- NTLM: &esv1.NTLMProtocol{UserName: ref, Password: ref},
- }
- Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
- createExternalSecret(f, "e2e-ntlm", key)
- expectNotReadyBecause(f, "e2e-ntlm", "external-secrets.io/type")
- expectNoTargetSecret(f)
- })
- It("pushes a secret and sends the templated body", func() {
- // The remote key has no dash on purpose. With spec.body unset the
- // provider builds the push body from the template
- // "{{ .remoteRef.<remoteKey> }}", so a remote key that is not a valid
- // Go template field name cannot be rendered. spec.body is set below,
- // which avoids that, but keeping the key simple keeps the failure mode
- // out of this spec.
- const remoteKey = "pushed"
- By("repointing the store at the push variable set and setting a body")
- store := &esv1.SecretStore{}
- Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
- Namespace: f.Namespace.Name, Name: f.Namespace.Name,
- }, store)).To(Succeed())
- base := store.DeepCopy()
- // The url and headers switch to .remoteRef.remoteKey: the push path never
- // populates .remoteRef.key, so naming it would render the literal
- // "<no value>" into the path. See the note on readKeyTemplate.
- store.Spec = prov.storeSpec(authSecretName, pushKeyTemplate)
- store.Spec.Provider.Webhook.Body = `{"pushed":"{{ .remoteRef.` + remoteKey + ` }}"}`
- Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
- source := &corev1.Secret{
- ObjectMeta: metav1.ObjectMeta{
- Name: "webhook-e2e-push-source",
- Namespace: f.Namespace.Name,
- },
- Data: map[string][]byte{"secret-key": []byte("pushed-value")},
- }
- Expect(f.CRClient.Create(GinkgoT().Context(), source)).To(Succeed())
- ps := &esv1alpha1.PushSecret{
- ObjectMeta: metav1.ObjectMeta{Name: "e2e-ps", Namespace: f.Namespace.Name},
- Spec: esv1alpha1.PushSecretSpec{
- RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
- SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{{Name: f.Namespace.Name}},
- Selector: esv1alpha1.PushSecretSelector{
- Secret: &esv1alpha1.PushSecretSecret{Name: source.Name},
- },
- Data: []esv1alpha1.PushSecretData{{
- Match: esv1alpha1.PushSecretMatch{
- SecretKey: "secret-key",
- RemoteRef: esv1alpha1.PushSecretRemoteRef{RemoteKey: remoteKey},
- },
- }},
- },
- }
- Expect(f.CRClient.Create(GinkgoT().Context(), ps)).To(Succeed())
- By("asserting what the backend received, not just that the push reported ready")
- Eventually(func(g Gomega) {
- requests := prov.backend.requestsFor(remoteKey)
- var pushes []recordedRequest
- for _, r := range requests {
- if r.Method == http.MethodPost {
- pushes = append(pushes, r)
- }
- }
- g.Expect(pushes).ToNot(BeEmpty(), "backend recorded no push for %q", remoteKey)
- last := pushes[len(pushes)-1]
- g.Expect(last.Body).To(MatchJSON(`{"pushed":"pushed-value"}`))
- g.Expect(last.Header.Get("Authorization")).To(Equal("Bearer " + authSecretValue))
- g.Expect(last.Header.Get("X-Remote-Key")).To(Equal(remoteKey))
- }, 2*time.Minute, 3*time.Second).Should(Succeed())
- By("confirming the pushed value is then readable back through the store")
- stored, ok := prov.backend.value(remoteKey)
- Expect(ok).To(BeTrue())
- Expect(stored).To(MatchJSON(`{"pushed":"pushed-value"}`))
- })
- It("reports dataFrom.find as unsupported", func() {
- // GetAllSecrets is a stub returning errNotImplemented, so find can only
- // be asserted as refused. Covering it keeps the gap explicit: if the
- // provider ever implements it, this spec fails and has to be rewritten
- // rather than quietly continuing to claim find does not work.
- es := &esv1.ExternalSecret{
- ObjectMeta: metav1.ObjectMeta{Name: "e2e-find", Namespace: f.Namespace.Name},
- Spec: esv1.ExternalSecretSpec{
- RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
- SecretStoreRef: esv1.SecretStoreRef{
- Name: f.Namespace.Name,
- Kind: esv1.SecretStoreKind,
- },
- Target: esv1.ExternalSecretTarget{Name: framework.TargetSecretName},
- DataFrom: []esv1.ExternalSecretDataFromRemoteRef{{
- Find: &esv1.ExternalSecretFind{
- Name: &esv1.FindName{RegExp: ".*"},
- },
- }},
- },
- }
- Expect(f.CRClient.Create(GinkgoT().Context(), es)).To(Succeed())
- expectNotReadyBecause(f, "e2e-find", "not implemented")
- expectNoTargetSecret(f)
- })
- })
- // createExternalSecret installs a single-key ExternalSecret reading key through
- // the store the provider created for this namespace.
- func createExternalSecret(f *framework.Framework, name, key string) {
- es := &esv1.ExternalSecret{
- ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: f.Namespace.Name},
- Spec: esv1.ExternalSecretSpec{
- RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
- SecretStoreRef: esv1.SecretStoreRef{
- Name: f.Namespace.Name,
- Kind: esv1.SecretStoreKind,
- },
- Target: esv1.ExternalSecretTarget{Name: framework.TargetSecretName},
- Data: []esv1.ExternalSecretData{{
- SecretKey: "value",
- RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: key},
- }},
- },
- }
- Expect(f.CRClient.Create(GinkgoT().Context(), es)).To(Succeed())
- }
- // replaceStoreSecret repoints the store's spec.secrets at another Secret,
- // keeping everything else identical.
- func replaceStoreSecret(f *framework.Framework, prov *Provider, secretName string) {
- store := &esv1.SecretStore{}
- Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
- Namespace: f.Namespace.Name, Name: f.Namespace.Name,
- }, store)).To(Succeed())
- base := store.DeepCopy()
- store.Spec = prov.storeSpec(secretName, readKeyTemplate)
- Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
- }
- // expectNotReadyBecause waits for the ExternalSecret to report a false Ready
- // condition AND for the reason it failed to mention want.
- //
- // Checking why it failed is the point: asserting only that it never became
- // ready would also pass on a typo in the fixture or a missing store, which is
- // how a suite ends up green about the wrong thing.
- //
- // The reason has to come from the Warning Event, not the condition. The
- // controller sets a fixed condition message ("could not get secret data from
- // provider", see markAsFailed) and routes the wrapped provider error to an
- // Event, so the condition message is identical for every provider failure and
- // asserting on it would be vacuous.
- func expectNotReadyBecause(f *framework.Framework, name, want string) {
- Eventually(func(g Gomega) {
- es := &esv1.ExternalSecret{}
- g.Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
- Namespace: f.Namespace.Name, Name: name,
- }, es)).To(Succeed())
- var ready *esv1.ExternalSecretStatusCondition
- for i := range es.Status.Conditions {
- if es.Status.Conditions[i].Type == esv1.ExternalSecretReady {
- ready = &es.Status.Conditions[i]
- }
- }
- g.Expect(ready).ToNot(BeNil(), "expected a Ready condition")
- g.Expect(ready.Status).To(Equal(corev1.ConditionFalse))
- events, err := f.KubeClientSet.CoreV1().Events(f.Namespace.Name).List(
- GinkgoT().Context(), metav1.ListOptions{
- FieldSelector: "involvedObject.name=" + name +
- ",involvedObject.kind=ExternalSecret",
- })
- g.Expect(err).ToNot(HaveOccurred())
- var messages []string
- for _, ev := range events.Items {
- messages = append(messages, ev.Message)
- }
- g.Expect(messages).To(ContainElement(ContainSubstring(want)),
- "no event explaining the refusal mentioned %q", want)
- }, 2*time.Minute, 3*time.Second).Should(Succeed())
- }
- // expectNoTargetSecret asserts the refused read produced no Secret at all.
- func expectNoTargetSecret(f *framework.Framework) {
- Consistently(func(g Gomega) {
- err := f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
- Namespace: f.Namespace.Name, Name: framework.TargetSecretName,
- }, &corev1.Secret{})
- g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
- }, 15*time.Second, 3*time.Second).Should(Succeed())
- }
|