Skip to content

Commit

Permalink
plugin/rest: Add GCP metadata server support
Browse files Browse the repository at this point in the history
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 <kelsey.hightower@gmail.com>
  • Loading branch information
kelseyhightower authored and tsandall committed Nov 24, 2020
1 parent 38ab0e1 commit 07e7867
Show file tree
Hide file tree
Showing 5 changed files with 416 additions and 4 deletions.
59 changes: 59 additions & 0 deletions docs/content/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
173 changes: 173 additions & 0 deletions plugins/rest/gcp.go
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions plugins/rest/gcp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 5 additions & 4 deletions plugins/rest/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
Loading

0 comments on commit 07e7867

Please sign in to comment.