provider_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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 crd
  14. import (
  15. "context"
  16. "errors"
  17. "fmt"
  18. "strings"
  19. "testing"
  20. "k8s.io/client-go/rest"
  21. kclient "sigs.k8s.io/controller-runtime/pkg/client"
  22. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  23. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  24. )
  25. // fakeBuildClient returns a buildClientFn that succeeds with the given plural and
  26. // scope, backed by a controller-runtime fake client (no objects). This bypasses
  27. // both the RESTMapper and the real cluster.
  28. func fakeBuildClient(plural string, namespaced bool) func(*rest.Config, esv1.CRDProviderResource) (kclient.Client, string, bool, error) {
  29. return func(_ *rest.Config, _ esv1.CRDProviderResource) (kclient.Client, string, bool, error) {
  30. return fakeCRDClient(namespaced), plural, namespaced, nil
  31. }
  32. }
  33. // fakeBuildClientErr returns a buildClientFn that always fails with the given error.
  34. func fakeBuildClientErr(err error) func(*rest.Config, esv1.CRDProviderResource) (kclient.Client, string, bool, error) {
  35. return func(_ *rest.Config, _ esv1.CRDProviderResource) (kclient.Client, string, bool, error) {
  36. return nil, "", true, err
  37. }
  38. }
  39. // providerWithFakeClient returns a Provider with a fake client builder injected,
  40. // bypassing both token fetch and the real cluster.
  41. // namespaced defaults to true when omitted (namespace-scoped CRD).
  42. func providerWithFakeClient(plural string, namespaced ...bool) *Provider {
  43. ns := true
  44. if len(namespaced) > 0 {
  45. ns = namespaced[0]
  46. }
  47. return &Provider{buildClientFn: fakeBuildClient(plural, ns)}
  48. }
  49. func makeStoreWithCRDProvider(prov *esv1.CRDProvider) esv1.GenericStore {
  50. return &esv1.SecretStore{
  51. Spec: esv1.SecretStoreSpec{
  52. Provider: &esv1.SecretStoreProvider{
  53. CRD: prov,
  54. },
  55. },
  56. }
  57. }
  58. func makeClusterStoreWithCRDProvider(prov *esv1.CRDProvider) esv1.GenericStore {
  59. return &esv1.ClusterSecretStore{
  60. Spec: esv1.SecretStoreSpec{
  61. Provider: &esv1.SecretStoreProvider{
  62. CRD: prov,
  63. },
  64. },
  65. }
  66. }
  67. // widgetResource is a valid CRDProviderResource used across tests.
  68. var widgetResource = esv1.CRDProviderResource{
  69. Group: "example.io",
  70. Version: "v1alpha1",
  71. Kind: "Widget",
  72. }
  73. // saAuth builds a KubernetesAuth that authenticates as the given ServiceAccount,
  74. // mirroring the in-cluster connection model shared with the Kubernetes provider.
  75. func saAuth(name string) *esv1.KubernetesAuth {
  76. return &esv1.KubernetesAuth{
  77. ServiceAccount: &esmeta.ServiceAccountSelector{Name: name},
  78. }
  79. }
  80. // defaultRESTCfg returns a minimal REST config used in provider construction tests.
  81. func defaultRESTCfg() *rest.Config {
  82. return &rest.Config{Host: "https://example.com", BearerToken: "tok"}
  83. }
  84. func TestProviderCapabilities(t *testing.T) {
  85. p := &Provider{}
  86. if got := p.Capabilities(); got != esv1.SecretStoreReadOnly {
  87. t.Fatalf("Capabilities() = %v, want %v", got, esv1.SecretStoreReadOnly)
  88. }
  89. }
  90. func TestValidateStore(t *testing.T) {
  91. tests := []struct {
  92. name string
  93. store esv1.GenericStore
  94. wantErr error
  95. wantMsg string
  96. wantWarnSubstring string
  97. }{
  98. {
  99. name: "missing provider config is ignored",
  100. store: &esv1.SecretStore{},
  101. },
  102. {
  103. // In-cluster: auth.serviceAccount with no server is the canonical
  104. // local-read configuration; the URL defaults to kubernetes.default.
  105. name: "in-cluster auth.serviceAccount without server is accepted",
  106. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  107. Auth: saAuth("reader"),
  108. Resource: widgetResource,
  109. }),
  110. },
  111. {
  112. name: "missing version",
  113. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "example.io", Kind: "Widget"}}),
  114. wantErr: errMissingVersion,
  115. },
  116. {
  117. name: "missing kind",
  118. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "example.io", Version: "v1alpha1"}}),
  119. wantErr: errMissingKind,
  120. },
  121. {
  122. name: "empty group is valid (core resource e.g. ConfigMap)",
  123. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "", Version: "v1", Kind: "ConfigMap"}}),
  124. },
  125. {
  126. name: "core v1 Secret is denied (exact case)",
  127. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "", Version: "v1", Kind: "Secret"}}),
  128. wantErr: errKindIsSecret,
  129. },
  130. {
  131. name: "core v1 secret is denied (lowercase)",
  132. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "", Version: "v1", Kind: "secret"}}),
  133. wantErr: errKindIsSecret,
  134. },
  135. {
  136. name: "core v1 SECRET is denied (uppercase)",
  137. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "", Version: "v1", Kind: "SECRET"}}),
  138. wantErr: errKindIsSecret,
  139. },
  140. {
  141. // Same Kind name on a different API group is a legitimate CRD;
  142. // only the core v1 Secret is blocked.
  143. name: "Secret kind in a non-core group is allowed",
  144. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "example.io", Version: "v1", Kind: "Secret"}}),
  145. },
  146. {
  147. // Different version of core "Secret" — also legitimate (no such
  148. // thing exists today, but the block is intentionally narrow).
  149. name: "core v2 Secret is allowed",
  150. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "", Version: "v2", Kind: "Secret"}}),
  151. },
  152. {
  153. name: "core group alias \"core\" still denies v1 Secret",
  154. store: makeStoreWithCRDProvider(&esv1.CRDProvider{Resource: esv1.CRDProviderResource{Group: "core", Version: "v1", Kind: "Secret"}}),
  155. wantErr: errKindIsSecret,
  156. },
  157. {
  158. name: "invalid whitelist name regex",
  159. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  160. Resource: widgetResource,
  161. Whitelist: &esv1.CRDProviderWhitelist{Rules: []esv1.CRDProviderWhitelistRule{{Name: "("}}},
  162. }),
  163. wantMsg: "invalid whitelist.rules[0].name regex",
  164. },
  165. {
  166. name: "invalid whitelist property regex",
  167. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  168. Resource: widgetResource,
  169. Whitelist: &esv1.CRDProviderWhitelist{Rules: []esv1.CRDProviderWhitelistRule{{Properties: []string{"("}}}},
  170. }),
  171. wantMsg: "invalid whitelist.rules[0].properties[0] regex",
  172. },
  173. {
  174. name: "empty whitelist rule is invalid",
  175. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  176. Resource: widgetResource,
  177. Whitelist: &esv1.CRDProviderWhitelist{Rules: []esv1.CRDProviderWhitelistRule{{}}},
  178. }),
  179. wantErr: errEmptyWhitelistRule,
  180. },
  181. {
  182. name: "valid config",
  183. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  184. Auth: saAuth("reader"),
  185. Resource: widgetResource,
  186. Whitelist: &esv1.CRDProviderWhitelist{Rules: []esv1.CRDProviderWhitelistRule{{
  187. Name: "^app-.*$",
  188. Properties: []string{"^spec\\..+$"},
  189. }}},
  190. }),
  191. },
  192. {
  193. name: "remote server with token auth is accepted",
  194. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  195. Resource: widgetResource,
  196. Server: esv1.KubernetesServer{
  197. URL: "https://k8s.example",
  198. CABundle: []byte("fake-ca"),
  199. },
  200. Auth: &esv1.KubernetesAuth{
  201. Token: &esv1.TokenAuth{
  202. BearerToken: esmeta.SecretKeySelector{Name: "t", Key: "k"},
  203. },
  204. },
  205. }),
  206. },
  207. {
  208. // server.url set without any credentials is a misconfiguration.
  209. name: "server.url without auth or authRef is rejected",
  210. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  211. Resource: widgetResource,
  212. Server: esv1.KubernetesServer{URL: "https://k8s.example"},
  213. }),
  214. wantMsg: "server.url requires auth or authRef",
  215. },
  216. {
  217. // authRef embeds a kubeconfig with the server address, so a
  218. // separate server.url is not required.
  219. name: "authRef without server.url is allowed",
  220. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  221. Resource: widgetResource,
  222. AuthRef: &esmeta.SecretKeySelector{Name: "kubeconfig", Key: "config"},
  223. }),
  224. },
  225. {
  226. name: "explicit connection TLS CA warning",
  227. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  228. Resource: widgetResource,
  229. Server: esv1.KubernetesServer{
  230. URL: "https://k8s.example",
  231. },
  232. Auth: &esv1.KubernetesAuth{
  233. Token: &esv1.TokenAuth{
  234. BearerToken: esmeta.SecretKeySelector{Name: "t", Key: "k"},
  235. },
  236. },
  237. }),
  238. wantWarnSubstring: "system certificate roots",
  239. },
  240. {
  241. name: "ClusterSecretStore CAProvider needs namespace",
  242. store: &esv1.ClusterSecretStore{
  243. Spec: esv1.SecretStoreSpec{
  244. Provider: &esv1.SecretStoreProvider{
  245. CRD: &esv1.CRDProvider{
  246. Resource: widgetResource,
  247. Server: esv1.KubernetesServer{
  248. URL: "https://x",
  249. CAProvider: &esv1.CAProvider{
  250. Type: esv1.CAProviderTypeSecret,
  251. Name: "ca",
  252. Key: "k",
  253. },
  254. },
  255. Auth: &esv1.KubernetesAuth{
  256. Token: &esv1.TokenAuth{
  257. BearerToken: esmeta.SecretKeySelector{Name: "t", Key: "k"},
  258. },
  259. },
  260. },
  261. },
  262. },
  263. },
  264. wantMsg: "CAProvider.namespace must not be empty",
  265. },
  266. {
  267. name: "SecretStore rejects CAProvider.namespace",
  268. store: &esv1.SecretStore{
  269. Spec: esv1.SecretStoreSpec{
  270. Provider: &esv1.SecretStoreProvider{
  271. CRD: &esv1.CRDProvider{
  272. Resource: widgetResource,
  273. Server: esv1.KubernetesServer{
  274. URL: "https://x",
  275. CAProvider: &esv1.CAProvider{
  276. Type: esv1.CAProviderTypeSecret,
  277. Name: "ca",
  278. Key: "k",
  279. Namespace: new("ns"),
  280. },
  281. },
  282. Auth: &esv1.KubernetesAuth{
  283. Token: &esv1.TokenAuth{
  284. BearerToken: esmeta.SecretKeySelector{Name: "t", Key: "k"},
  285. },
  286. },
  287. },
  288. },
  289. },
  290. },
  291. wantMsg: "CAProvider.namespace must be empty with SecretStore",
  292. },
  293. {
  294. // A SecretStore reads only its own namespace, so a namespace rule
  295. // can never match and is rejected to surface the misconfiguration.
  296. name: "SecretStore rejects whitelist namespace rule",
  297. store: makeStoreWithCRDProvider(&esv1.CRDProvider{
  298. Auth: saAuth("reader"),
  299. Resource: widgetResource,
  300. Whitelist: &esv1.CRDProviderWhitelist{Rules: []esv1.CRDProviderWhitelistRule{{Namespace: "^prod$"}}},
  301. }),
  302. wantMsg: "whitelist.rules[0].namespace is not supported for a SecretStore",
  303. },
  304. {
  305. // A ClusterSecretStore reads across namespaces, so a namespace rule
  306. // is a legitimate restriction and must be accepted.
  307. name: "ClusterSecretStore allows whitelist namespace rule",
  308. store: makeClusterStoreWithCRDProvider(&esv1.CRDProvider{
  309. Auth: saAuth("reader"),
  310. Resource: widgetResource,
  311. Whitelist: &esv1.CRDProviderWhitelist{Rules: []esv1.CRDProviderWhitelistRule{{Namespace: "^prod$"}}},
  312. }),
  313. },
  314. }
  315. p := &Provider{}
  316. for _, tt := range tests {
  317. t.Run(tt.name, func(t *testing.T) {
  318. warnings, err := p.ValidateStore(tt.store)
  319. if tt.wantErr != nil || tt.wantMsg != "" {
  320. if err == nil {
  321. t.Fatalf("ValidateStore() error = nil, want error")
  322. }
  323. if tt.wantErr != nil && !errors.Is(err, tt.wantErr) {
  324. t.Fatalf("ValidateStore() error = %v, want %v", err, tt.wantErr)
  325. }
  326. if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) {
  327. t.Fatalf("ValidateStore() error = %q, want substring %q", err.Error(), tt.wantMsg)
  328. }
  329. return
  330. }
  331. if err != nil {
  332. t.Fatalf("ValidateStore() unexpected error: %v", err)
  333. }
  334. if tt.wantWarnSubstring != "" {
  335. var b strings.Builder
  336. for _, w := range warnings {
  337. b.WriteString(w)
  338. }
  339. if !strings.Contains(b.String(), tt.wantWarnSubstring) {
  340. t.Fatalf("ValidateStore() warnings = %v, want substring %q", warnings, tt.wantWarnSubstring)
  341. }
  342. }
  343. })
  344. }
  345. }
  346. func TestGetProvider(t *testing.T) {
  347. tests := []struct {
  348. name string
  349. store esv1.GenericStore
  350. wantErr error
  351. }{
  352. {name: "nil store", store: nil, wantErr: errMissingStore},
  353. {name: "missing provider", store: &esv1.SecretStore{}, wantErr: errMissingCRDProvider},
  354. {name: "missing crd provider", store: &esv1.SecretStore{Spec: esv1.SecretStoreSpec{Provider: &esv1.SecretStoreProvider{}}}, wantErr: errMissingCRDProvider},
  355. }
  356. for _, tt := range tests {
  357. t.Run(tt.name, func(t *testing.T) {
  358. _, err := getProvider(tt.store)
  359. if !errors.Is(err, tt.wantErr) {
  360. t.Fatalf("getProvider() error = %v, want %v", err, tt.wantErr)
  361. }
  362. })
  363. }
  364. t.Run("valid store", func(t *testing.T) {
  365. want := &esv1.CRDProvider{Auth: saAuth("reader"), Resource: widgetResource}
  366. got, err := getProvider(makeStoreWithCRDProvider(want))
  367. if err != nil {
  368. t.Fatalf("getProvider() unexpected error: %v", err)
  369. }
  370. if got != want {
  371. t.Fatalf("getProvider() returned wrong provider pointer")
  372. }
  373. })
  374. }
  375. func TestProviderMetadata(t *testing.T) {
  376. if _, ok := NewProvider().(*Provider); !ok {
  377. t.Fatalf("NewProvider() did not return *Provider")
  378. }
  379. spec := ProviderSpec()
  380. if spec == nil || spec.CRD == nil {
  381. t.Fatalf("ProviderSpec() returned nil CRD provider")
  382. }
  383. if got := MaintenanceStatus(); got != esv1.MaintenanceStatusMaintained {
  384. t.Fatalf("MaintenanceStatus() = %v, want %v", got, esv1.MaintenanceStatusMaintained)
  385. }
  386. }
  387. func TestNewClientInternal(t *testing.T) {
  388. ctx := context.Background()
  389. store := makeStoreWithCRDProvider(&esv1.CRDProvider{Auth: saAuth("reader"), Resource: widgetResource})
  390. t.Run("newClient returns getProvider error on nil store", func(t *testing.T) {
  391. _, err := providerWithFakeClient("widgets").newClient(ctx, nil, nil, nil, "default")
  392. if !errors.Is(err, errMissingStore) {
  393. t.Fatalf("newClient() error = %v, want %v", err, errMissingStore)
  394. }
  395. })
  396. t.Run("referent ClusterSecretStore returns a validation stub at store bootstrap", func(t *testing.T) {
  397. // ClusterSecretStore, auth.serviceAccount with no explicit namespace, and
  398. // an empty namespace (the store-validation call). newClient must short
  399. // circuit before building any REST connection and return a referent stub
  400. // whose Validate() reports "unknown".
  401. clusterStore := makeClusterStoreWithCRDProvider(&esv1.CRDProvider{
  402. Auth: saAuth("reader"),
  403. Resource: widgetResource,
  404. })
  405. client, err := (&Provider{}).newClient(ctx, clusterStore, nil, nil, "")
  406. if err != nil {
  407. t.Fatalf("newClient() unexpected error: %v", err)
  408. }
  409. c, ok := client.(*Client)
  410. if !ok {
  411. t.Fatalf("returned %T, want *Client", client)
  412. }
  413. if !c.referent {
  414. t.Fatalf("expected a referent stub client")
  415. }
  416. if c.kube != nil {
  417. t.Fatalf("referent stub must not carry a client")
  418. }
  419. res, err := c.Validate()
  420. if err != nil {
  421. t.Fatalf("Validate() unexpected error: %v", err)
  422. }
  423. if res != esv1.ValidationResultUnknown {
  424. t.Fatalf("Validate() = %v, want %v", res, esv1.ValidationResultUnknown)
  425. }
  426. })
  427. t.Run("newClientWithRESTConfig returns getProvider error on nil store", func(t *testing.T) {
  428. _, err := providerWithFakeClient("widgets").newClientWithRESTConfig(context.Background(), nil, defaultRESTCfg(), "default")
  429. if !errors.Is(err, errMissingStore) {
  430. t.Fatalf("newClientWithRESTConfig() error = %v, want %v", err, errMissingStore)
  431. }
  432. })
  433. t.Run("resource resolution error is propagated", func(t *testing.T) {
  434. mapErr := fmt.Errorf("group/version/kind not registered")
  435. _, err := (&Provider{buildClientFn: fakeBuildClientErr(mapErr)}).newClientWithRESTConfig(context.Background(), store, defaultRESTCfg(), "default")
  436. if !errors.Is(err, mapErr) {
  437. t.Fatalf("newClientWithRESTConfig() error = %v, want %v", err, mapErr)
  438. }
  439. })
  440. t.Run("creates client for namespaced store", func(t *testing.T) {
  441. client, err := providerWithFakeClient("widgets").newClientWithRESTConfig(context.Background(), store, defaultRESTCfg(), "app-ns")
  442. if err != nil {
  443. t.Fatalf("newClientWithRESTConfig() unexpected error: %v", err)
  444. }
  445. c, ok := client.(*Client)
  446. if !ok {
  447. t.Fatalf("returned %T, want *Client", client)
  448. }
  449. if c.store.Auth.ServiceAccount.Name != "reader" {
  450. t.Fatalf("client SA = %q, want %q", c.store.Auth.ServiceAccount.Name, "reader")
  451. }
  452. if c.namespace != "app-ns" || c.kube == nil {
  453. t.Fatalf("client: ns=%q kube=%v", c.namespace, c.kube)
  454. }
  455. })
  456. t.Run("creates client for cluster store (empty namespace)", func(t *testing.T) {
  457. client, err := providerWithFakeClient("widgets").newClientWithRESTConfig(context.Background(), store, defaultRESTCfg(), "")
  458. if err != nil {
  459. t.Fatalf("newClientWithRESTConfig() unexpected error: %v", err)
  460. }
  461. c, ok := client.(*Client)
  462. if !ok {
  463. t.Fatalf("returned %T, want *Client", client)
  464. }
  465. if c.namespace != "" {
  466. t.Fatalf("client namespace = %q, want empty", c.namespace)
  467. }
  468. })
  469. t.Run("cluster-scoped resource sets namespaced false on client", func(t *testing.T) {
  470. client, err := providerWithFakeClient("clusterdbspecs", false).newClientWithRESTConfig(context.Background(), store, defaultRESTCfg(), "default")
  471. if err != nil {
  472. t.Fatalf("newClientWithRESTConfig() unexpected error: %v", err)
  473. }
  474. if client.(*Client).namespaced {
  475. t.Fatalf("expected cluster-scoped client (namespaced=false)")
  476. }
  477. })
  478. }