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