webhook.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package webhook
  14. import (
  15. "net/http"
  16. "time"
  17. // nolint
  18. . "github.com/onsi/ginkgo/v2"
  19. // nolint
  20. . "github.com/onsi/gomega"
  21. corev1 "k8s.io/api/core/v1"
  22. apierrors "k8s.io/apimachinery/pkg/api/errors"
  23. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  24. "sigs.k8s.io/controller-runtime/pkg/client"
  25. "github.com/external-secrets/external-secrets-e2e/framework"
  26. "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/common"
  27. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  28. esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
  29. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  30. )
  31. var _ = Describe("[webhook]", Label("webhook"), func() {
  32. f := framework.New("eso-webhook")
  33. prov := NewProvider(f)
  34. // Only the shared entries the provider can actually satisfy are wired up.
  35. // GetSecret ignores remoteRef.property entirely (it uses the ref for
  36. // templating and then applies result.jsonPath), so every property-based
  37. // entry in cases/common would fail here for a reason that is a documented
  38. // provider limitation rather than a regression.
  39. DescribeTable("sync secrets",
  40. framework.TableFuncWithExternalSecret(f, prov),
  41. Entry(common.SimpleDataSync(f)),
  42. Entry(common.SyncWithoutTargetName(f)),
  43. Entry(common.JSONDataFromSync(f)),
  44. Entry(common.JSONDataFromRewrite(f)),
  45. Entry(common.SSHKeySync(f)),
  46. Entry(common.DeletionPolicyDelete(f)),
  47. )
  48. It("templates the url, headers and spec.secrets values", func() {
  49. const key = "templating"
  50. prov.CreateSecret(key, framework.SecretEntry{Value: "templated-value"})
  51. createExternalSecret(f, "e2e-tpl", key)
  52. // Type must be set: equalSecrets compares it, and ESO leaves the target
  53. // to be defaulted to Opaque unless spec.target.template.type says
  54. // otherwise, so an empty Type here would never converge.
  55. _, err := f.WaitForSecretValue(f.Namespace.Name, framework.TargetSecretName,
  56. &corev1.Secret{
  57. Type: corev1.SecretTypeOpaque,
  58. Data: map[string][]byte{"value": []byte("templated-value")},
  59. })
  60. Expect(err).ToNot(HaveOccurred())
  61. // The request the controller actually sent proves the templating, which
  62. // a green sync alone would not: the url carried the remote key, and the
  63. // headers resolved both remoteRef and the .<name>.<keyInSecret> form
  64. // that spec.secrets values are addressed by.
  65. requests := prov.backend.requestsFor(key)
  66. Expect(requests).ToNot(BeEmpty(), "backend recorded no request for %q", key)
  67. got := requests[len(requests)-1]
  68. Expect(got.Method).To(Equal(http.MethodGet))
  69. Expect(got.Path).To(HaveSuffix(kvPath + key))
  70. Expect(got.Header.Get("Authorization")).To(Equal("Bearer " + authSecretValue))
  71. Expect(got.Header.Get("X-Remote-Key")).To(Equal(key))
  72. })
  73. It("refuses a store secret without the external-secrets.io/type label", func() {
  74. const key = "unlabelled"
  75. prov.CreateSecret(key, framework.SecretEntry{Value: "never-read"})
  76. By("pointing the store at a Secret that carries no type label")
  77. prov.CreateAuthSecret("webhook-e2e-unlabelled", false)
  78. replaceStoreSecret(f, prov, "webhook-e2e-unlabelled")
  79. createExternalSecret(f, "e2e-unlabelled", key)
  80. expectNotReadyBecause(f, "e2e-unlabelled", "external-secrets.io/type")
  81. expectNoTargetSecret(f)
  82. })
  83. It("refuses ntlm secret refs without the external-secrets.io/type label", func() {
  84. const key = "ntlm"
  85. prov.CreateSecret(key, framework.SecretEntry{Value: "never-read"})
  86. By("adding ntlm auth whose Secrets carry no type label")
  87. prov.CreateAuthSecret("webhook-e2e-ntlm", false)
  88. store := &esv1.SecretStore{}
  89. Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
  90. Namespace: f.Namespace.Name, Name: f.Namespace.Name,
  91. }, store)).To(Succeed())
  92. base := store.DeepCopy()
  93. ref := esmeta.SecretKeySelector{Name: "webhook-e2e-ntlm", Key: authSecretKey}
  94. store.Spec.Provider.Webhook.Auth = &esv1.AuthorizationProtocol{
  95. NTLM: &esv1.NTLMProtocol{UserName: ref, Password: ref},
  96. }
  97. Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
  98. createExternalSecret(f, "e2e-ntlm", key)
  99. expectNotReadyBecause(f, "e2e-ntlm", "external-secrets.io/type")
  100. expectNoTargetSecret(f)
  101. })
  102. It("pushes a secret and sends the templated body", func() {
  103. // The remote key has no dash on purpose. With spec.body unset the
  104. // provider builds the push body from the template
  105. // "{{ .remoteRef.<remoteKey> }}", so a remote key that is not a valid
  106. // Go template field name cannot be rendered. spec.body is set below,
  107. // which avoids that, but keeping the key simple keeps the failure mode
  108. // out of this spec.
  109. const remoteKey = "pushed"
  110. By("repointing the store at the push variable set and setting a body")
  111. store := &esv1.SecretStore{}
  112. Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
  113. Namespace: f.Namespace.Name, Name: f.Namespace.Name,
  114. }, store)).To(Succeed())
  115. base := store.DeepCopy()
  116. // The url and headers switch to .remoteRef.remoteKey: the push path never
  117. // populates .remoteRef.key, so naming it would render the literal
  118. // "<no value>" into the path. See the note on readKeyTemplate.
  119. store.Spec = prov.storeSpec(authSecretName, pushKeyTemplate)
  120. store.Spec.Provider.Webhook.Body = `{"pushed":"{{ .remoteRef.` + remoteKey + ` }}"}`
  121. Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
  122. source := &corev1.Secret{
  123. ObjectMeta: metav1.ObjectMeta{
  124. Name: "webhook-e2e-push-source",
  125. Namespace: f.Namespace.Name,
  126. },
  127. Data: map[string][]byte{"secret-key": []byte("pushed-value")},
  128. }
  129. Expect(f.CRClient.Create(GinkgoT().Context(), source)).To(Succeed())
  130. ps := &esv1alpha1.PushSecret{
  131. ObjectMeta: metav1.ObjectMeta{Name: "e2e-ps", Namespace: f.Namespace.Name},
  132. Spec: esv1alpha1.PushSecretSpec{
  133. RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
  134. SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{{Name: f.Namespace.Name}},
  135. Selector: esv1alpha1.PushSecretSelector{
  136. Secret: &esv1alpha1.PushSecretSecret{Name: source.Name},
  137. },
  138. Data: []esv1alpha1.PushSecretData{{
  139. Match: esv1alpha1.PushSecretMatch{
  140. SecretKey: "secret-key",
  141. RemoteRef: esv1alpha1.PushSecretRemoteRef{RemoteKey: remoteKey},
  142. },
  143. }},
  144. },
  145. }
  146. Expect(f.CRClient.Create(GinkgoT().Context(), ps)).To(Succeed())
  147. By("asserting what the backend received, not just that the push reported ready")
  148. Eventually(func(g Gomega) {
  149. requests := prov.backend.requestsFor(remoteKey)
  150. var pushes []recordedRequest
  151. for _, r := range requests {
  152. if r.Method == http.MethodPost {
  153. pushes = append(pushes, r)
  154. }
  155. }
  156. g.Expect(pushes).ToNot(BeEmpty(), "backend recorded no push for %q", remoteKey)
  157. last := pushes[len(pushes)-1]
  158. g.Expect(last.Body).To(MatchJSON(`{"pushed":"pushed-value"}`))
  159. g.Expect(last.Header.Get("Authorization")).To(Equal("Bearer " + authSecretValue))
  160. g.Expect(last.Header.Get("X-Remote-Key")).To(Equal(remoteKey))
  161. }, 2*time.Minute, 3*time.Second).Should(Succeed())
  162. By("confirming the pushed value is then readable back through the store")
  163. stored, ok := prov.backend.value(remoteKey)
  164. Expect(ok).To(BeTrue())
  165. Expect(stored).To(MatchJSON(`{"pushed":"pushed-value"}`))
  166. })
  167. It("reports dataFrom.find as unsupported", func() {
  168. // GetAllSecrets is a stub returning errNotImplemented, so find can only
  169. // be asserted as refused. Covering it keeps the gap explicit: if the
  170. // provider ever implements it, this spec fails and has to be rewritten
  171. // rather than quietly continuing to claim find does not work.
  172. es := &esv1.ExternalSecret{
  173. ObjectMeta: metav1.ObjectMeta{Name: "e2e-find", Namespace: f.Namespace.Name},
  174. Spec: esv1.ExternalSecretSpec{
  175. RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
  176. SecretStoreRef: esv1.SecretStoreRef{
  177. Name: f.Namespace.Name,
  178. Kind: esv1.SecretStoreKind,
  179. },
  180. Target: esv1.ExternalSecretTarget{Name: framework.TargetSecretName},
  181. DataFrom: []esv1.ExternalSecretDataFromRemoteRef{{
  182. Find: &esv1.ExternalSecretFind{
  183. Name: &esv1.FindName{RegExp: ".*"},
  184. },
  185. }},
  186. },
  187. }
  188. Expect(f.CRClient.Create(GinkgoT().Context(), es)).To(Succeed())
  189. expectNotReadyBecause(f, "e2e-find", "not implemented")
  190. expectNoTargetSecret(f)
  191. })
  192. })
  193. // createExternalSecret installs a single-key ExternalSecret reading key through
  194. // the store the provider created for this namespace.
  195. func createExternalSecret(f *framework.Framework, name, key string) {
  196. es := &esv1.ExternalSecret{
  197. ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: f.Namespace.Name},
  198. Spec: esv1.ExternalSecretSpec{
  199. RefreshInterval: &metav1.Duration{Duration: 5 * time.Second},
  200. SecretStoreRef: esv1.SecretStoreRef{
  201. Name: f.Namespace.Name,
  202. Kind: esv1.SecretStoreKind,
  203. },
  204. Target: esv1.ExternalSecretTarget{Name: framework.TargetSecretName},
  205. Data: []esv1.ExternalSecretData{{
  206. SecretKey: "value",
  207. RemoteRef: esv1.ExternalSecretDataRemoteRef{Key: key},
  208. }},
  209. },
  210. }
  211. Expect(f.CRClient.Create(GinkgoT().Context(), es)).To(Succeed())
  212. }
  213. // replaceStoreSecret repoints the store's spec.secrets at another Secret,
  214. // keeping everything else identical.
  215. func replaceStoreSecret(f *framework.Framework, prov *Provider, secretName string) {
  216. store := &esv1.SecretStore{}
  217. Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
  218. Namespace: f.Namespace.Name, Name: f.Namespace.Name,
  219. }, store)).To(Succeed())
  220. base := store.DeepCopy()
  221. store.Spec = prov.storeSpec(secretName, readKeyTemplate)
  222. Expect(f.CRClient.Patch(GinkgoT().Context(), store, client.MergeFrom(base))).To(Succeed())
  223. }
  224. // expectNotReadyBecause waits for the ExternalSecret to report a false Ready
  225. // condition AND for the reason it failed to mention want.
  226. //
  227. // Checking why it failed is the point: asserting only that it never became
  228. // ready would also pass on a typo in the fixture or a missing store, which is
  229. // how a suite ends up green about the wrong thing.
  230. //
  231. // The reason has to come from the Warning Event, not the condition. The
  232. // controller sets a fixed condition message ("could not get secret data from
  233. // provider", see markAsFailed) and routes the wrapped provider error to an
  234. // Event, so the condition message is identical for every provider failure and
  235. // asserting on it would be vacuous.
  236. func expectNotReadyBecause(f *framework.Framework, name, want string) {
  237. Eventually(func(g Gomega) {
  238. es := &esv1.ExternalSecret{}
  239. g.Expect(f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
  240. Namespace: f.Namespace.Name, Name: name,
  241. }, es)).To(Succeed())
  242. var ready *esv1.ExternalSecretStatusCondition
  243. for i := range es.Status.Conditions {
  244. if es.Status.Conditions[i].Type == esv1.ExternalSecretReady {
  245. ready = &es.Status.Conditions[i]
  246. }
  247. }
  248. g.Expect(ready).ToNot(BeNil(), "expected a Ready condition")
  249. g.Expect(ready.Status).To(Equal(corev1.ConditionFalse))
  250. events, err := f.KubeClientSet.CoreV1().Events(f.Namespace.Name).List(
  251. GinkgoT().Context(), metav1.ListOptions{
  252. FieldSelector: "involvedObject.name=" + name +
  253. ",involvedObject.kind=ExternalSecret",
  254. })
  255. g.Expect(err).ToNot(HaveOccurred())
  256. var messages []string
  257. for _, ev := range events.Items {
  258. messages = append(messages, ev.Message)
  259. }
  260. g.Expect(messages).To(ContainElement(ContainSubstring(want)),
  261. "no event explaining the refusal mentioned %q", want)
  262. }, 2*time.Minute, 3*time.Second).Should(Succeed())
  263. }
  264. // expectNoTargetSecret asserts the refused read produced no Secret at all.
  265. func expectNoTargetSecret(f *framework.Framework) {
  266. Consistently(func(g Gomega) {
  267. err := f.CRClient.Get(GinkgoT().Context(), client.ObjectKey{
  268. Namespace: f.Namespace.Name, Name: framework.TargetSecretName,
  269. }, &corev1.Secret{})
  270. g.Expect(apierrors.IsNotFound(err)).To(BeTrue())
  271. }, 15*time.Second, 3*time.Second).Should(Succeed())
  272. }