istio.io/istio@v0.0.0-20240520182934-d79c90f27776/security/pkg/credentialfetcher/plugin/gce_test.go (about)

     1  // Copyright Istio Authors
     2  //
     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  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package plugin
    16  
    17  import (
    18  	"fmt"
    19  	"os"
    20  	"testing"
    21  	"time"
    22  
    23  	"github.com/google/uuid"
    24  
    25  	"istio.io/istio/pkg/test/util/retry"
    26  )
    27  
    28  var (
    29  	// thirdPartyJwt is generated in a testing K8s cluster, using the "istio-token" projected volume.
    30  	// Token is issued at 2020-04-04 22:13:54 Pacific Daylight time.
    31  	// Expiration time is 2020-04-05 10:13:54 Pacific Daylight time.
    32  	thirdPartyJwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9." +
    33  		"eyJhdWQiOlsieW9uZ2dhbmdsLWlzdGlvLTQuc3ZjLmlkLmdvb2ciXSwiZXhwIjoxNTg2MTA2ODM0LCJpYXQiOjE1" +
    34  		"ODYwNjM2MzQsImlzcyI6Imh0dHBzOi8vY29udGFpbmVyLmdvb2dsZWFwaXMuY29tL3YxL3Byb2plY3RzL3lvbmdn" +
    35  		"YW5nbC1pc3Rpby00L2xvY2F0aW9ucy91cy1jZW50cmFsMS1hL2NsdXN0ZXJzL2NsdXN0ZXItMyIsImt1YmVybmV0" +
    36  		"ZXMuaW8iOnsibmFtZXNwYWNlIjoiZm9vIiwicG9kIjp7Im5hbWUiOiJodHRwYmluLTY0Nzc2YmY3OGQtanFsNWIi" +
    37  		"LCJ1aWQiOiI5YWQ3NTcxYi03NjBhLTExZWEtODllNy00MjAxMGE4MDAxYzEifSwic2VydmljZWFjY291bnQiOnsi" +
    38  		"bmFtZSI6Imh0dHBiaW4iLCJ1aWQiOiI5OWY2NWY1MC03NjBhLTExZWEtODllNy00MjAxMGE4MDAxYzEifX0sIm5i" +
    39  		"ZiI6MTU4NjA2MzYzNCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmZvbzpodHRwYmluIn0.XWSCdarBR0cx" +
    40  		"MlVV5X9pvgI9m0lyO-17B45aBKJBIQjvjluKXqxnCuIeD3X2ItLkCUzmKUa3ftTjUUov1MJ89MdBngNfUP7IfwnD" +
    41  		"2dBl7Jtju0-Ks7aTFOkgtoMYqNnQ1VSDTAOfNpdZVUnsR_oY8obXSQR_H4uMcaNOGED2RX5HLBWFlvymtn4JXuyg" +
    42  		"_rpOrJ8dv-snrmO3LT9y-zaUnZqSceDC8skzStrJIRvsRkO8GEcoQd5VwDn-UVgOcqWb-S-vgSjdtwBsnGPXsh_I" +
    43  		"NZCq3ftr0Qu8-IxsIjpMjhLmAGTH1bR324aqLTYhAXp6fk06Pe3T9stCY5acSeadKA"
    44  	// firstPartyJwt is generated in a testing K8s cluster. It is the default service account JWT.
    45  	// No expiration time.
    46  	firstPartyJwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9." +
    47  		"eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9u" +
    48  		"YW1lc3BhY2UiOiJmb28iLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlY3JldC5uYW1lIjoiaHR0cGJp" +
    49  		"bi10b2tlbi14cWRncCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUi" +
    50  		"OiJodHRwYmluIiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQudWlkIjoiOTlm" +
    51  		"NjVmNTAtNzYwYS0xMWVhLTg5ZTctNDIwMTBhODAwMWMxIiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmZv" +
    52  		"bzpodHRwYmluIn0.4kIl9TRjXEw6DfhtR-LdpxsAlYjJgC6Ly1DY_rqYY4h0haxcXB3kYZ3b2He-3fqOBryz524W" +
    53  		"KkZscZgvs5L-sApmvlqdUG61TMAl7josB0x4IMHm1NS995LNEaXiI4driffwfopvqc_z3lVKfbF9j-mBgnCepxz3" +
    54  		"UyWo5irFa3qcwbOUB9kuuUNGBdtbFBN5yIYLpfa9E-MtTX_zJ9fQ9j2pi8Z4ljii0tEmPmRxokHkmG_xNJjUkxKU" +
    55  		"WZf4bLDdCEjVFyshNae-FdxiUVyeyYorTYzwZZYQch9MJeedg4keKKUOvCCJUlKixd2qAe-H7r15RPmo4AU5O5YL" +
    56  		"65xiNg"
    57  )
    58  
    59  func TestShouldRotate(t *testing.T) {
    60  	jwtExp := time.Date(2020, time.April, 5, 10, 13, 54, 0, time.FixedZone("PDT", -int((7*time.Hour).Seconds())))
    61  	testCases := map[string]struct {
    62  		jwt            string
    63  		now            time.Time
    64  		expectedRotate bool
    65  	}{
    66  		"remaining life time is in grace period": {
    67  			jwt:            thirdPartyJwt,
    68  			now:            jwtExp.Add(time.Duration(-10) * time.Minute),
    69  			expectedRotate: true,
    70  		},
    71  		"remaining life time is not in grace period": {
    72  			jwt:            thirdPartyJwt,
    73  			now:            jwtExp.Add(time.Duration(-30) * time.Minute),
    74  			expectedRotate: false,
    75  		},
    76  		"no cached credential": {
    77  			now:            time.Now(),
    78  			expectedRotate: true,
    79  		},
    80  		"no expiration in token": {
    81  			jwt:            firstPartyJwt,
    82  			now:            time.Now(),
    83  			expectedRotate: true,
    84  		},
    85  		"invalid token": {
    86  			jwt:            "invalid-token",
    87  			now:            time.Now(),
    88  			expectedRotate: true,
    89  		},
    90  	}
    91  
    92  	for id, tc := range testCases {
    93  		t.Run(id, func(t *testing.T) {
    94  			p := GCEPlugin{
    95  				tokenCache: tc.jwt,
    96  			}
    97  			if rotate := p.shouldRotate(tc.now); rotate != tc.expectedRotate {
    98  				t.Errorf("%s, shouldRotate(%s)=%t, expected %t",
    99  					id, tc.now.String(), rotate, tc.expectedRotate)
   100  			}
   101  		})
   102  	}
   103  }
   104  
   105  func creatJWTFile(path string) error {
   106  	if path == "" {
   107  		return nil
   108  	}
   109  	f, err := os.Create(path)
   110  	if err != nil {
   111  		return err
   112  	}
   113  	f.Close()
   114  	return nil
   115  }
   116  
   117  func getJWTFromFile(path string) (string, error) {
   118  	if path == "" {
   119  		return "", nil
   120  	}
   121  	data, err := os.ReadFile(path)
   122  	if err != nil {
   123  		return "", err
   124  	}
   125  	return string(data), nil
   126  }
   127  
   128  func getTokenFromServer(t *testing.T, p *GCEPlugin, callReps int) ([]string, []error) {
   129  	t.Helper()
   130  	var tokens []string
   131  	var errs []error
   132  	for i := 0; i < callReps; i++ {
   133  		token, err := p.GetPlatformCredential()
   134  		tokens = append(tokens, token)
   135  		errs = append(errs, err)
   136  	}
   137  	return tokens, errs
   138  }
   139  
   140  func verifyError(t *testing.T, id string, errs []error, expectedErr error) {
   141  	t.Helper()
   142  	for _, err := range errs {
   143  		if err == nil && expectedErr != nil || err != nil && expectedErr == nil {
   144  			t.Errorf("%s, GetPlatformCredential() returns err: %v, want: %v", id, err, expectedErr)
   145  		} else if err != nil && expectedErr != nil && err.Error() != expectedErr.Error() {
   146  			t.Errorf("%s, GetPlatformCredential() returns err: %v, want: %v", id, err, expectedErr)
   147  		}
   148  	}
   149  }
   150  
   151  func verifyToken(t *testing.T, id, jwtPath string, tokens []string, expectedToken string) {
   152  	t.Helper()
   153  	for i, token := range tokens {
   154  		// if expectedToken is not set, mock metadata server returns an auto-generated fake token.
   155  		wantToken := fmt.Sprintf("%s%d", fakeTokenPrefix, i+1)
   156  		if expectedToken != "" {
   157  			wantToken = expectedToken
   158  		}
   159  		if token != wantToken {
   160  			t.Errorf("%s, #%d call to GetPlatformCredential() returns token: %s, want: %s",
   161  				id, i, token, wantToken)
   162  		}
   163  		if jwtPath != "" && i+1 == len(tokens) {
   164  			// If this is the last round, and JWT path is set, check JWT in the file.
   165  			jwtFile, err := getJWTFromFile(jwtPath)
   166  			if err != nil {
   167  				t.Fatalf("%s, failed to read token from file %s: %v", id, jwtPath, err)
   168  			}
   169  			if jwtFile != wantToken {
   170  				t.Errorf("%s, %s has token %s, want %s", id, jwtPath, jwtFile, wantToken)
   171  			}
   172  		}
   173  	}
   174  }
   175  
   176  func TestGCEPlugin(t *testing.T) {
   177  	testCases := map[string]struct {
   178  		jwt           string
   179  		jwtPath       string
   180  		expectedToken string
   181  		expectedCall  int
   182  		expectedErr   error
   183  	}{
   184  		"get VM credential": {
   185  			jwt:           thirdPartyJwt,
   186  			jwtPath:       fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
   187  			expectedToken: thirdPartyJwt,
   188  			expectedCall:  1,
   189  		},
   190  		"jwt path not set": {
   191  			jwt:          thirdPartyJwt,
   192  			expectedCall: 1,
   193  			expectedErr:  fmt.Errorf("jwtPath is unset"),
   194  		},
   195  		"fetch credential multiple times": {
   196  			expectedCall: 5,
   197  			jwtPath:      fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
   198  		},
   199  	}
   200  
   201  	SetTokenRotation(false)
   202  	ms, err := StartMetadataServer()
   203  	if err != nil {
   204  		t.Fatalf("StartMetadataServer() returns err: %v", err)
   205  	}
   206  	t.Cleanup(func() {
   207  		ms.Stop()
   208  		SetTokenRotation(true)
   209  	})
   210  
   211  	for id, tc := range testCases {
   212  		p := GCEPlugin{
   213  			tokenCache: tc.jwt,
   214  			jwtPath:    tc.jwtPath,
   215  		}
   216  		if err := creatJWTFile(tc.jwtPath); err != nil {
   217  			t.Fatalf("%s, creatJWTFile() returns err: %v", id, err)
   218  		}
   219  		ms.Reset()
   220  		ms.setToken(tc.jwt)
   221  
   222  		tokens, errs := getTokenFromServer(t, &p, tc.expectedCall)
   223  
   224  		verifyError(t, id, errs, tc.expectedErr)
   225  		if tc.expectedErr == nil {
   226  			if ms.NumGetTokenCall() != tc.expectedCall {
   227  				t.Errorf("%s, metadata server receives %d calls, want %d",
   228  					id, ms.NumGetTokenCall(), tc.expectedCall)
   229  			}
   230  			if tc.jwtPath != "" {
   231  				verifyToken(t, id, tc.jwtPath, tokens, tc.expectedToken)
   232  			}
   233  		}
   234  	}
   235  }
   236  
   237  func TestTokenRotationJob(t *testing.T) {
   238  	testCases := map[string]struct {
   239  		jwt           string
   240  		jwtPath       string
   241  		expectedToken string
   242  		expectedCall  int
   243  	}{
   244  		// mock metadata server returns an expired token, that forces rotation job
   245  		// to fetch new token during each rotation.
   246  		"expired token needs rotation": {
   247  			jwt:           thirdPartyJwt,
   248  			jwtPath:       fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
   249  			expectedToken: thirdPartyJwt,
   250  			expectedCall:  3,
   251  		},
   252  		// mock metadata server returns a token which has no exp field, that forces rotation job
   253  		// to fetch new token during each rotation.
   254  		"token with no expiration time needs rotation": {
   255  			jwt:           firstPartyJwt,
   256  			jwtPath:       fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
   257  			expectedToken: firstPartyJwt,
   258  			expectedCall:  3,
   259  		},
   260  		// mock metadata server returns an invalid token, that forces rotation job
   261  		// to fetch new token during each rotation.
   262  		"invalid token needs rotation": {
   263  			jwt:           "invalid-token-section-1.invalid-token-section-2",
   264  			jwtPath:       fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
   265  			expectedCall:  3,
   266  			expectedToken: "invalid-token-section-1.invalid-token-section-2",
   267  		},
   268  	}
   269  
   270  	// starts rotation job every 0.5 second.
   271  	rotationInterval = 500 * time.Millisecond
   272  	SetTokenRotation(true)
   273  	ms, err := StartMetadataServer()
   274  	if err != nil {
   275  		t.Fatalf("StartMetadataServer() returns err: %v", err)
   276  	}
   277  	t.Cleanup(func() {
   278  		ms.Stop()
   279  		rotationInterval = 5 * time.Minute
   280  	})
   281  
   282  	for id, tc := range testCases {
   283  		// These tests should not run in parallel because they share one metadata server.
   284  		t.Run(id, func(t *testing.T) {
   285  			p := CreateGCEPlugin("", tc.jwtPath, "")
   286  			if err := creatJWTFile(tc.jwtPath); err != nil {
   287  				t.Fatalf("%s, creatJWTFile() returns err: %v", id, err)
   288  			}
   289  			ms.Reset()
   290  			ms.setToken(tc.jwt)
   291  
   292  			// Verify that rotation job is kicked multiple times.
   293  			retryTimeout := time.Duration(5+tc.expectedCall) * rotationInterval
   294  			retry.UntilSuccessOrFail(t, func() error {
   295  				callNumber := ms.NumGetTokenCall()
   296  				if callNumber < tc.expectedCall {
   297  					return fmt.Errorf("%s, got %d token fetch calls, expected %d",
   298  						id, callNumber, tc.expectedCall)
   299  				}
   300  				return nil
   301  			}, retry.Delay(time.Second), retry.Timeout(retryTimeout))
   302  
   303  			p.Stop()
   304  		})
   305  	}
   306  }