workload_identity_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  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 secretmanager
  14. import (
  15. "context"
  16. "encoding/json"
  17. "errors"
  18. "fmt"
  19. "io"
  20. "net/http"
  21. "net/http/httptest"
  22. "testing"
  23. "cloud.google.com/go/iam/credentials/apiv1/credentialspb"
  24. "github.com/googleapis/gax-go/v2"
  25. "github.com/stretchr/testify/assert"
  26. "golang.org/x/oauth2"
  27. authv1 "k8s.io/api/authentication/v1"
  28. v1 "k8s.io/api/core/v1"
  29. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  30. k8sv1 "k8s.io/client-go/kubernetes/typed/core/v1"
  31. "sigs.k8s.io/controller-runtime/pkg/client"
  32. clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
  33. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  34. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  35. )
  36. type workloadIdentityTest struct {
  37. name string
  38. expTS bool
  39. expToken *oauth2.Token
  40. expErr string
  41. genAccessToken func(context.Context, *credentialspb.GenerateAccessTokenRequest, ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error)
  42. genIDBindToken func(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error)
  43. genSAToken func(c context.Context, s1 []string, s2, s3 string) (*authv1.TokenRequest, error)
  44. instMetadata map[string]string
  45. store esv1.GenericStore
  46. kubeObjects []client.Object
  47. }
  48. func TestWorkloadIdentity(t *testing.T) {
  49. clusterSANamespace := "foobar"
  50. tbl := []*workloadIdentityTest{
  51. composeTestcase(
  52. defaultTestCase("should skip when no workload identity is configured: TokenSource and error must be nil"),
  53. withStore(&esv1.SecretStore{
  54. Spec: esv1.SecretStoreSpec{
  55. Provider: &esv1.SecretStoreProvider{
  56. GCPSM: &esv1.GCPSMProvider{},
  57. },
  58. },
  59. }),
  60. ),
  61. composeTestcase(
  62. defaultTestCase("return access token from GenerateAccessTokenRequest with SecretStore"),
  63. withStore(defaultStore()),
  64. expTokenSource(),
  65. expectToken(defaultGenAccessToken),
  66. ),
  67. composeTestcase(
  68. defaultTestCase("return idBindToken when no annotation is set with SecretStore"),
  69. expTokenSource(),
  70. expectToken(defaultIDBindToken),
  71. withStore(defaultStore()),
  72. withK8sResources([]client.Object{
  73. &v1.ServiceAccount{
  74. ObjectMeta: metav1.ObjectMeta{
  75. Name: "example",
  76. Namespace: "default",
  77. Annotations: map[string]string{},
  78. },
  79. },
  80. }),
  81. ),
  82. composeTestcase(
  83. defaultTestCase("ClusterSecretStore: referent auth / service account without namespace"),
  84. expTokenSource(),
  85. withStore(
  86. composeStore(defaultClusterStore()),
  87. ),
  88. withK8sResources([]client.Object{
  89. &v1.ServiceAccount{
  90. ObjectMeta: metav1.ObjectMeta{
  91. Name: "example",
  92. Namespace: "default",
  93. Annotations: map[string]string{},
  94. },
  95. },
  96. }),
  97. ),
  98. composeTestcase(
  99. defaultTestCase("ClusterSecretStore: invalid service account"),
  100. expErr("foobar"),
  101. withStore(
  102. composeStore(defaultClusterStore()),
  103. ),
  104. withK8sResources([]client.Object{
  105. &v1.ServiceAccount{
  106. ObjectMeta: metav1.ObjectMeta{
  107. Name: "does not exist",
  108. Namespace: "default",
  109. Annotations: map[string]string{},
  110. },
  111. },
  112. }),
  113. ),
  114. composeTestcase(
  115. defaultTestCase("return access token from GenerateAccessTokenRequest with ClusterSecretStore"),
  116. expTokenSource(),
  117. expectToken(defaultGenAccessToken),
  118. withStore(
  119. composeStore(defaultClusterStore(), withSANamespace(clusterSANamespace)),
  120. ),
  121. withK8sResources([]client.Object{
  122. &v1.ServiceAccount{
  123. ObjectMeta: metav1.ObjectMeta{
  124. Name: "example",
  125. Namespace: clusterSANamespace,
  126. Annotations: map[string]string{
  127. gcpSAAnnotation: "example",
  128. },
  129. },
  130. },
  131. }),
  132. ),
  133. composeTestcase(
  134. defaultTestCase("lookup cluster id from instance metadata"),
  135. expTokenSource(),
  136. expectToken(defaultGenAccessToken),
  137. withStore(
  138. composeStore(defaultStore(), withClusterID("", "", "")),
  139. ),
  140. withInstMetadata(map[string]string{
  141. "project-id": "1234",
  142. "cluster-location": "example",
  143. "cluster-name": "foobar",
  144. }),
  145. ),
  146. }
  147. for _, row := range tbl {
  148. t.Run(row.name, func(t *testing.T) {
  149. fakeIam := &fakeIAMClient{generateAccessTokenFunc: row.genAccessToken}
  150. fakeMeta := &fakeMetadataClient{metadata: row.instMetadata}
  151. fakeIDBGen := &fakeIDBindTokenGen{generateFunc: row.genIDBindToken}
  152. fakeSATG := &fakeSATokenGen{GenerateFunc: row.genSAToken}
  153. w := &workloadIdentity{
  154. iamClient: fakeIam,
  155. metadataClient: fakeMeta,
  156. idBindTokenGenerator: fakeIDBGen,
  157. saTokenGenerator: fakeSATG,
  158. }
  159. cb := clientfake.NewClientBuilder()
  160. cb.WithObjects(row.kubeObjects...)
  161. client := cb.Build()
  162. isCluster := row.store.GetTypeMeta().Kind == esv1.ClusterSecretStoreKind
  163. ts, err := w.TokenSource(context.Background(), row.store.GetSpec().Provider.GCPSM.Auth, isCluster, client, "default")
  164. // assert err
  165. if row.expErr == "" {
  166. assert.NoError(t, err)
  167. } else {
  168. assert.Error(t, err, row.expErr)
  169. }
  170. // assert ts
  171. if row.expTS {
  172. assert.NotNil(t, ts)
  173. if row.expToken != nil {
  174. tk, err := ts.Token()
  175. assert.NoError(t, err)
  176. assert.EqualValues(t, tk, row.expToken)
  177. }
  178. } else {
  179. assert.Nil(t, ts)
  180. }
  181. })
  182. }
  183. }
  184. func TestClusterProjectID(t *testing.T) {
  185. clusterID, err := clusterProjectID(defaultStore().GetSpec())
  186. assert.Nil(t, err)
  187. assert.Equal(t, clusterID, "1234")
  188. externalClusterID, err := clusterProjectID(defaultExternalStore().GetSpec())
  189. assert.Nil(t, err)
  190. assert.Equal(t, externalClusterID, "5678")
  191. }
  192. func TestSATokenGen(t *testing.T) {
  193. corev1 := &fakeK8sV1{}
  194. g := &k8sSATokenGenerator{
  195. corev1: corev1,
  196. }
  197. token, err := g.Generate(context.Background(), []string{"my-fake-audience"}, "bar", "default")
  198. assert.Nil(t, err)
  199. assert.Equal(t, token.Status.Token, defaultSAToken)
  200. assert.Equal(t, token.Spec.Audiences[0], "my-fake-audience")
  201. }
  202. func TestIDBTokenGen(t *testing.T) {
  203. srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  204. payload := make(map[string]string)
  205. rb, err := io.ReadAll(r.Body)
  206. assert.Nil(t, err)
  207. err = json.Unmarshal(rb, &payload)
  208. assert.Nil(t, err)
  209. assert.Equal(t, payload["audience"], "identitynamespace:some-idpool:some-id-provider")
  210. bt, err := json.Marshal(&oauth2.Token{
  211. AccessToken: "12345",
  212. })
  213. assert.Nil(t, err)
  214. rw.WriteHeader(http.StatusOK)
  215. rw.Write(bt)
  216. }))
  217. defer srv.Close()
  218. gen := &gcpIDBindTokenGenerator{
  219. targetURL: srv.URL,
  220. }
  221. token, err := gen.Generate(context.Background(), http.DefaultClient, "some-token", "some-idpool", "some-id-provider")
  222. assert.Nil(t, err)
  223. assert.Equal(t, token.AccessToken, "12345")
  224. }
  225. type testCaseMutator func(tc *workloadIdentityTest)
  226. func composeTestcase(tc *workloadIdentityTest, mutators ...testCaseMutator) *workloadIdentityTest {
  227. for _, m := range mutators {
  228. m(tc)
  229. }
  230. return tc
  231. }
  232. func withStore(store esv1.GenericStore) testCaseMutator {
  233. return func(tc *workloadIdentityTest) {
  234. tc.store = store
  235. }
  236. }
  237. func expTokenSource() testCaseMutator {
  238. return func(tc *workloadIdentityTest) {
  239. tc.expTS = true
  240. }
  241. }
  242. func expectToken(token string) testCaseMutator {
  243. return func(tc *workloadIdentityTest) {
  244. tc.expToken = &oauth2.Token{
  245. AccessToken: token,
  246. }
  247. }
  248. }
  249. func expErr(err string) testCaseMutator {
  250. return func(tc *workloadIdentityTest) {
  251. tc.expErr = err
  252. }
  253. }
  254. func withK8sResources(objs []client.Object) testCaseMutator {
  255. return func(tc *workloadIdentityTest) {
  256. tc.kubeObjects = objs
  257. }
  258. }
  259. func withInstMetadata(metadata map[string]string) testCaseMutator {
  260. return func(tc *workloadIdentityTest) {
  261. tc.instMetadata = metadata
  262. }
  263. }
  264. var (
  265. defaultGenAccessToken = "default-gen-access-token"
  266. defaultIDBindToken = "default-id-bind-token"
  267. defaultSAToken = "default-k8s-sa-token"
  268. )
  269. func defaultTestCase(name string) *workloadIdentityTest {
  270. return &workloadIdentityTest{
  271. name: name,
  272. genAccessToken: func(c context.Context, gatr *credentialspb.GenerateAccessTokenRequest, co ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error) {
  273. return &credentialspb.GenerateAccessTokenResponse{
  274. AccessToken: defaultGenAccessToken,
  275. }, nil
  276. },
  277. genIDBindToken: func(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error) {
  278. return &oauth2.Token{
  279. AccessToken: defaultIDBindToken,
  280. }, nil
  281. },
  282. genSAToken: func(c context.Context, s1 []string, s2, s3 string) (*authv1.TokenRequest, error) {
  283. return &authv1.TokenRequest{
  284. Status: authv1.TokenRequestStatus{
  285. Token: defaultSAToken,
  286. },
  287. }, nil
  288. },
  289. instMetadata: map[string]string{
  290. "project-id": "1234",
  291. },
  292. kubeObjects: []client.Object{
  293. &v1.ServiceAccount{
  294. ObjectMeta: metav1.ObjectMeta{
  295. Name: "example",
  296. Namespace: "default",
  297. Annotations: map[string]string{
  298. gcpSAAnnotation: "example",
  299. },
  300. },
  301. },
  302. },
  303. }
  304. }
  305. func defaultStore() *esv1.SecretStore {
  306. return &esv1.SecretStore{
  307. ObjectMeta: metav1.ObjectMeta{
  308. Name: "foobar",
  309. Namespace: "default",
  310. },
  311. Spec: defaultStoreSpec(),
  312. }
  313. }
  314. func defaultExternalStore() *esv1.SecretStore {
  315. return &esv1.SecretStore{
  316. ObjectMeta: metav1.ObjectMeta{
  317. Name: "foobar",
  318. Namespace: "default",
  319. },
  320. Spec: defaultExternalStoreSpec(),
  321. }
  322. }
  323. func defaultClusterStore() *esv1.ClusterSecretStore {
  324. return &esv1.ClusterSecretStore{
  325. TypeMeta: metav1.TypeMeta{
  326. Kind: esv1.ClusterSecretStoreKind,
  327. },
  328. ObjectMeta: metav1.ObjectMeta{
  329. Name: "foobar",
  330. },
  331. Spec: defaultStoreSpec(),
  332. }
  333. }
  334. func defaultStoreSpec() esv1.SecretStoreSpec {
  335. return esv1.SecretStoreSpec{
  336. Provider: &esv1.SecretStoreProvider{
  337. GCPSM: &esv1.GCPSMProvider{
  338. Auth: esv1.GCPSMAuth{
  339. WorkloadIdentity: &esv1.GCPWorkloadIdentity{
  340. ServiceAccountRef: esmeta.ServiceAccountSelector{
  341. Name: "example",
  342. },
  343. ClusterLocation: "example",
  344. ClusterName: "foobar",
  345. },
  346. },
  347. ProjectID: "1234",
  348. },
  349. },
  350. }
  351. }
  352. func defaultExternalStoreSpec() esv1.SecretStoreSpec {
  353. return esv1.SecretStoreSpec{
  354. Provider: &esv1.SecretStoreProvider{
  355. GCPSM: &esv1.GCPSMProvider{
  356. Auth: esv1.GCPSMAuth{
  357. WorkloadIdentity: &esv1.GCPWorkloadIdentity{
  358. ServiceAccountRef: esmeta.ServiceAccountSelector{
  359. Name: "example",
  360. },
  361. ClusterLocation: "example",
  362. ClusterName: "foobar",
  363. ClusterProjectID: "5678",
  364. },
  365. },
  366. ProjectID: "1234",
  367. },
  368. },
  369. }
  370. }
  371. type storeMutator func(spc esv1.GenericStore)
  372. func composeStore(store esv1.GenericStore, mutators ...storeMutator) esv1.GenericStore {
  373. for _, m := range mutators {
  374. m(store)
  375. }
  376. return store
  377. }
  378. func withClusterID(project, location, name string) storeMutator {
  379. return func(store esv1.GenericStore) {
  380. spc := store.GetSpec()
  381. spc.Provider.GCPSM.Auth.WorkloadIdentity.ClusterProjectID = project
  382. spc.Provider.GCPSM.Auth.WorkloadIdentity.ClusterLocation = location
  383. spc.Provider.GCPSM.Auth.WorkloadIdentity.ClusterName = name
  384. }
  385. }
  386. func withSANamespace(namespace string) storeMutator {
  387. return func(store esv1.GenericStore) {
  388. spc := store.GetSpec()
  389. spc.Provider.GCPSM.Auth.WorkloadIdentity.ServiceAccountRef.Namespace = &namespace
  390. }
  391. }
  392. // fake IDBindToken Generator.
  393. type fakeIDBindTokenGen struct {
  394. generateFunc func(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error)
  395. }
  396. func (g *fakeIDBindTokenGen) Generate(ctx context.Context, client *http.Client, k8sToken, idPool, idProvider string) (*oauth2.Token, error) {
  397. return g.generateFunc(ctx, client, k8sToken, idPool, idProvider)
  398. }
  399. // fake IAM Client.
  400. type fakeIAMClient struct {
  401. generateAccessTokenFunc func(context.Context, *credentialspb.GenerateAccessTokenRequest, ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error)
  402. }
  403. func (f *fakeIAMClient) GenerateAccessToken(ctx context.Context, req *credentialspb.GenerateAccessTokenRequest, opts ...gax.CallOption) (*credentialspb.GenerateAccessTokenResponse, error) {
  404. return f.generateAccessTokenFunc(ctx, req, opts...)
  405. }
  406. func (f *fakeIAMClient) Close() error {
  407. return nil
  408. }
  409. // fake Metadata Client.
  410. type fakeMetadataClient struct {
  411. metadata map[string]string
  412. }
  413. func (f *fakeMetadataClient) InstanceAttributeValueWithContext(ctx context.Context, attr string) (string, error) {
  414. if val, ok := f.metadata[attr]; ok {
  415. return val, nil
  416. }
  417. return "", fmt.Errorf("attr %s not found", attr)
  418. }
  419. func (f *fakeMetadataClient) ProjectIDWithContext(ctx context.Context) (string, error) {
  420. if val, ok := f.metadata["project-id"]; ok {
  421. return val, nil
  422. }
  423. return "", errors.New("attr project-id not found")
  424. }
  425. // fake SA Token Generator.
  426. type fakeSATokenGen struct {
  427. GenerateFunc func(context.Context, []string, string, string) (*authv1.TokenRequest, error)
  428. }
  429. func (f *fakeSATokenGen) Generate(ctx context.Context, idPool []string, namespace, name string) (*authv1.TokenRequest, error) {
  430. return f.GenerateFunc(ctx, idPool, namespace, name)
  431. }
  432. // fake k8s client for creating tokens.
  433. type fakeK8sV1 struct {
  434. k8sv1.CoreV1Interface
  435. }
  436. func (m *fakeK8sV1) ServiceAccounts(_ string) k8sv1.ServiceAccountInterface {
  437. return &fakeK8sV1SA{v1mock: m}
  438. }
  439. // Mock the K8s service account client.
  440. type fakeK8sV1SA struct {
  441. k8sv1.ServiceAccountInterface
  442. v1mock *fakeK8sV1
  443. }
  444. func (ma *fakeK8sV1SA) CreateToken(
  445. _ context.Context,
  446. _ string,
  447. tokenRequest *authv1.TokenRequest,
  448. _ metav1.CreateOptions,
  449. ) (*authv1.TokenRequest, error) {
  450. tokenRequest.Status.Token = defaultSAToken
  451. return tokenRequest, nil
  452. }