From 07e7867c1bd9c508622d1c1e3cb046cf38d0724a Mon Sep 17 00:00:00 2001 From: Kelsey Hightower Date: Tue, 6 Oct 2020 01:31:27 -0700 Subject: [PATCH] plugin/rest: Add GCP metadata server support Adds support for fetching access and identity tokens from a GCP metadata server. Identity tokens are used to authenticate to third party applications running behind Google authentication proxies such as containers deployed to Google's Cloud Run. Access tokens are used to authenticate to first party GCP services such as Google Cloud Storage. Signed-off-by: Kelsey Hightower --- docs/content/configuration.md | 59 ++++++++++++ plugins/rest/gcp.go | 173 ++++++++++++++++++++++++++++++++++ plugins/rest/gcp_test.go | 103 ++++++++++++++++++++ plugins/rest/rest.go | 9 +- plugins/rest/rest_test.go | 76 +++++++++++++++ 5 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 plugins/rest/gcp.go create mode 100644 plugins/rest/gcp_test.go mode change 100755 => 100644 plugins/rest/rest_test.go diff --git a/docs/content/configuration.md b/docs/content/configuration.md index e0926de922..0706f7a191 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -347,6 +347,65 @@ services: url: https://s2/ ``` +#### GCP Metadata Token + +OPA will authenticate with a GCP [access token](https://cloud.google.com/run/docs/securing/service-identity#access_tokens) or [identity token](https://cloud.google.com/run/docs/securing/service-identity) fetched from the [Compute Metadata Server](https://cloud.google.com/compute/docs/storing-retrieving-metadata). When one or more `scopes` is provided an access token is fetched. When a non-empty `audience` is provided an identity token is fetched. An audience or `scopes` array is required. + +When authenticating to native GCP services such as [Google Cloud Storage](https://cloud.google.com/storage) an access token should be used with the appropriate set of scopes required by the target resource. When authenticating to a third party application such as an application hosted on Google Cloud Run an identity token should be used. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +|`services[_].credentials.gcp_metadata.audience`|`string`|No|The audience to use when fetching identity tokens.| +|`services[_].credentials.gcp_metadata.endpoint`|`string`|No|The metadata endpoint to use.| +|`services[_].credentials.gcp_metadata.scopes`|`array`|No|The set of scopes to use when fetching access token.| +|`services[_].credentials.gcp_metadata.access_token_path`|`string`|No|The access token metadata path to use.| +|`services[_].credentials.gcp_metadata.id_token_path`|`string`|No|The identity token metadata path to use.| + +##### Example + +Using a [Cloud Run](https://cloud.google.com/run) service as a bundle service backend. + +```yaml +services: + cloudrun: + url: ${BUNDLE_SERVICE_URL} + response_header_timeout_seconds: 5 + credentials: + gcp_metadata: + audience: ${BUNDLE_SERVICE_URL} + +bundles: + authz: + service: cloudrun + resource: bundles/http/example/authz.tar.gz + persist: true + polling: + min_delay_seconds: 60 + max_delay_seconds: 120 +``` + +Using [Google Cloud Storage](https://cloud.google.com/storage) as a bundle service backend. + +```yaml +services: + gcs: + url: https://storage.googleapis.com/storage/v1/b/${BUCKET_NAME}/o + response_header_timeout_seconds: 5 + credentials: + gcp_metadata: + scopes: + - "https://www.googleapis.com/auth/devstorage.read_only" + +bundles: + authz: + service: gcs + resource: 'bundle.tar.gz?alt=media' + persist: true + polling: + min_delay_seconds: 60 + max_delay_seconds: 120 +``` + ### Miscellaneous | Field | Type | Required | Description | diff --git a/plugins/rest/gcp.go b/plugins/rest/gcp.go new file mode 100644 index 0000000000..bd6d43c122 --- /dev/null +++ b/plugins/rest/gcp.go @@ -0,0 +1,173 @@ +// Copyright 2020 The OPA Authors. All rights reserved. +// Use of this source code is governed by an Apache2 +// license that can be found in the LICENSE file. + +package rest + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" +) + +var ( + defaultGCPMetadataEndpoint = "http://metadata.google.internal" + defaultAccessTokenPath = "/computeMetadata/v1/instance/service-accounts/default/token" + defaultIdentityTokenPath = "/computeMetadata/v1/instance/service-accounts/default/identity" +) + +// AccessToken holds a GCP access token. +type AccessToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` +} + +type gcpMetadataError struct { + err error + endpoint string + statusCode int +} + +func (e *gcpMetadataError) Error() string { + return fmt.Sprintf("error retrieving gcp ID token from %s %d: %v", e.endpoint, e.statusCode, e.err) +} + +func (e *gcpMetadataError) Unwrap() error { return e.err } + +var ( + errGCPMetadataNotFound = errors.New("not found") + errGCPMetadataInvalidRequest = errors.New("invalid request") + errGCPMetadataUnexpected = errors.New("unexpected error") +) + +// gcpMetadataAuthPlugin represents authentication via GCP metadata service. +type gcpMetadataAuthPlugin struct { + AccessTokenPath string `json:"access_token_path"` + Audience string `json:"audience"` + Endpoint string `json:"endpoint"` + IdentityTokenPath string `json:"identity_token_path"` + Scopes []string `json:"scopes"` +} + +func (ap *gcpMetadataAuthPlugin) NewClient(c Config) (*http.Client, error) { + if ap.Audience == "" && len(ap.Scopes) == 0 { + return nil, errors.New("audience or scopes is required when gcp metadata is enabled") + } + + if ap.Audience != "" && len(ap.Scopes) > 0 { + return nil, errors.New("either audience or scopes can be set, not both, when gcp metadata is enabled") + } + + if ap.Endpoint == "" { + ap.Endpoint = defaultGCPMetadataEndpoint + } + + if ap.AccessTokenPath == "" { + ap.AccessTokenPath = defaultAccessTokenPath + } + + if ap.IdentityTokenPath == "" { + ap.IdentityTokenPath = defaultIdentityTokenPath + } + + t, err := defaultTLSConfig(c) + if err != nil { + return nil, err + } + + return defaultRoundTripperClient(t, *c.ResponseHeaderTimeoutSeconds), nil +} + +func (ap *gcpMetadataAuthPlugin) Prepare(req *http.Request) error { + var err error + var token string + + if ap.Audience != "" { + token, err = identityTokenFromMetadataService(ap.Endpoint, ap.IdentityTokenPath, ap.Audience) + if err != nil { + return fmt.Errorf("error retrieving identity token from gcp metadata service: %w", err) + } + } + + if len(ap.Scopes) != 0 { + token, err = accessTokenFromMetadataService(ap.Endpoint, ap.AccessTokenPath, ap.Scopes) + if err != nil { + return fmt.Errorf("error retrieving access token from gcp metadata service: %w", err) + } + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", token)) + return nil +} + +// accessTokenFromMetadataService returns an access token based on the scopes. +func accessTokenFromMetadataService(endpoint, path string, scopes []string) (string, error) { + s := strings.Join(scopes, ",") + + e := fmt.Sprintf("%s%s?scopes=%s", endpoint, path, s) + + data, err := gcpMetadataServiceRequest(e) + if err != nil { + return "", err + } + + var accessToken AccessToken + err = json.Unmarshal(data, &accessToken) + if err != nil { + return "", err + } + + return accessToken.AccessToken, nil +} + +// identityTokenFromMetadataService returns an identity token based on the audience. +func identityTokenFromMetadataService(endpoint, path, audience string) (string, error) { + e := fmt.Sprintf("%s%s?audience=%s", endpoint, path, audience) + + data, err := gcpMetadataServiceRequest(e) + if err != nil { + return "", err + } + return string(data), nil +} + +func gcpMetadataServiceRequest(endpoint string) ([]byte, error) { + request, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + + request.Header.Add("Metadata-Flavor", "Google") + + timeout := time.Duration(5) * time.Second + httpClient := http.Client{Timeout: timeout} + + response, err := httpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + switch s := response.StatusCode; s { + case 200: + break + case 400: + return nil, &gcpMetadataError{errGCPMetadataInvalidRequest, endpoint, s} + case 404: + return nil, &gcpMetadataError{errGCPMetadataNotFound, endpoint, s} + default: + return nil, &gcpMetadataError{errGCPMetadataUnexpected, endpoint, s} + } + + data, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return data, nil +} diff --git a/plugins/rest/gcp_test.go b/plugins/rest/gcp_test.go new file mode 100644 index 0000000000..2082dd3084 --- /dev/null +++ b/plugins/rest/gcp_test.go @@ -0,0 +1,103 @@ +package rest + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGCPMetadataAuthPlugin(t *testing.T) { + idToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U" + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return + })) + defer s.Close() + + ts := httptest.NewServer(http.Handler(&gcpMetadataHandler{idToken})) + defer ts.Close() + + config := fmt.Sprintf(`{ + "name": "foo", + "url": "%s", + "allow_insecure_tls": true, + "credentials": { + "gcp_metadata": { + "audience": "https://example.org", + "endpoint": "%s" + } + } + }`, s.URL, ts.URL) + client, err := New([]byte(config)) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + ctx := context.Background() + _, err = client.Do(ctx, "GET", "test") + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestIdentityTokenFromMetadataService(t *testing.T) { + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U" + + ts := httptest.NewServer(http.Handler(&gcpMetadataHandler{token})) + defer ts.Close() + + tests := []struct { + audience string + identityToken string + identityTokenPath string + err error + }{ + {"https://example.org", token, defaultIdentityTokenPath, nil}, + {"", "", defaultIdentityTokenPath, errGCPMetadataInvalidRequest}, + {"https://example.org", "", "/status/bad/request", errGCPMetadataInvalidRequest}, + {"https://example.org", "", "/status/not/found", errGCPMetadataNotFound}, + {"https://example.org", "", "/status/internal/server/error", errGCPMetadataUnexpected}, + } + + for _, tt := range tests { + token, err := identityTokenFromMetadataService(ts.URL, tt.identityTokenPath, tt.audience) + if !errors.Is(err, tt.err) { + t.Fatalf("Unexpected error, got %v, want %v", err, tt.err) + } + + if token != tt.identityToken { + t.Fatalf("Unexpected id token, got %v, want %v", token, tt.identityToken) + } + } +} + +type gcpMetadataHandler struct { + identityToken string +} + +func (h *gcpMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + audience := r.URL.Query()["audience"][0] + + if audience == "" { + http.Error(w, "", http.StatusBadRequest) + return + } + + switch path { + case defaultIdentityTokenPath: + fmt.Fprint(w, h.identityToken) + case "/status/bad/request": + http.Error(w, "", http.StatusBadRequest) + case "/status/not/found": + http.Error(w, "", http.StatusNotFound) + case "/status/internal/server/error": + http.Error(w, "", http.StatusInternalServerError) + default: + http.Error(w, "", http.StatusNotFound) + } +} diff --git a/plugins/rest/rest.go b/plugins/rest/rest.go index 6df3374b87..ca09d8b42d 100644 --- a/plugins/rest/rest.go +++ b/plugins/rest/rest.go @@ -41,10 +41,11 @@ type Config struct { AllowInsureTLS bool `json:"allow_insecure_tls,omitempty"` ResponseHeaderTimeoutSeconds *int64 `json:"response_header_timeout_seconds,omitempty"` Credentials struct { - Bearer *bearerAuthPlugin `json:"bearer,omitempty"` - OAuth2 *oauth2ClientCredentialsAuthPlugin `json:"oauth2,omitempty"` - ClientTLS *clientTLSAuthPlugin `json:"client_tls,omitempty"` - S3Signing *awsSigningAuthPlugin `json:"s3_signing,omitempty"` + Bearer *bearerAuthPlugin `json:"bearer,omitempty"` + OAuth2 *oauth2ClientCredentialsAuthPlugin `json:"oauth2,omitempty"` + ClientTLS *clientTLSAuthPlugin `json:"client_tls,omitempty"` + S3Signing *awsSigningAuthPlugin `json:"s3_signing,omitempty"` + GCPMetadata *gcpMetadataAuthPlugin `json:"gcp_metadata,omitempty"` } `json:"credentials"` } diff --git a/plugins/rest/rest_test.go b/plugins/rest/rest_test.go old mode 100755 new mode 100644 index 063efaae01..5d887d050a --- a/plugins/rest/rest_test.go +++ b/plugins/rest/rest_test.go @@ -317,6 +317,82 @@ func TestNew(t *testing.T) { awsWebIdentityTokenFileEnvVar: "TEST", }, }, + { + name: "ValidGCPMetadataIDTokenOptions", + input: `{ + "name": "foo", + "url": "https://localhost", + "credentials": { + "gcp_metadata": { + "audience": "https://localhost" + } + } + }`, + }, + { + name: "ValidGCPMetadataAccessTokenOptions", + input: `{ + "name": "foo", + "url": "https://localhost", + "credentials": { + "gcp_metadata": { + "scopes": ["storage.read_only"] + } + } + }`, + }, + { + name: "EmptyGCPMetadataOptions", + input: `{ + "name": "foo", + "url": "http://localhost", + "credentials": { + "gcp_metadata": { + } + } + }`, + wantErr: true, + }, + { + name: "EmptyGCPMetadataIDTokenAudienceOption", + input: `{ + "name": "foo", + "url": "https://localhost", + "credentials": { + "gcp_metadata": { + "audience": "" + } + } + }`, + wantErr: true, + }, + { + name: "EmptyGCPMetadataAccessTokenScopesOption", + input: `{ + "name": "foo", + "url": "https://localhost", + "credentials": { + "gcp_metadata": { + "scopes": [] + } + } + }`, + wantErr: true, + }, + { + name: "InvalidGCPMetadataOptions", + input: `{ + "name": "foo", + "url": "https://localhost", + "credentials": { + "gcp_metadata": { + "audience": "https://localhost", + "scopes": ["storage.read_only"] + } + } + }`, + wantErr: true, + }, } var results []Client