Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add impersonate pacakge #927

Merged
merged 9 commits into from Mar 24, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 32 additions & 0 deletions impersonate/doc.go
@@ -0,0 +1,32 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package impersonate is used to impersonate Google Credentials.
//
// Required IAM roles
codyoss marked this conversation as resolved.
Show resolved Hide resolved
//
// In order to impersonate a service account the base service account must have
// the Service Account Token Creator role, roles/iam.serviceAccountTokenCreator,
// on the service account being impersonated. See
// https://cloud.google.com/iam/docs/understanding-service-accounts.
//
// Optionally, delegates can be used during impersonation if the base service
// account lacks the token creator role on the target. When using delegates,
// each service account must be granted roles/iam.serviceAccountTokenCreator
// on the next service account in the delgation chain.
//
// For example, if a base service account of SA1 is trying to impersonate target
// service account SA2 while using delegate service accounts DSA1 and DSA2,
// the following must be true:
//
// 1. Base service account SA1 has roles/iam.serviceAccountTokenCreator on
// DSA1.
// 2. DSA1 has roles/iam.serviceAccountTokenCreator on DSA2.
// 3. DSA2 has roles/iam.serviceAccountTokenCreator on target SA2.
//
// If the base credential is an authorized user and not a service account, or if
// the option WithQuotaProject is set, the target service account must have a
// role that grants the serviceusage.services.use permission such as
// roles/serviceusage.serviceUsageConsumer.
package impersonate
97 changes: 97 additions & 0 deletions impersonate/example_test.go
@@ -0,0 +1,97 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package impersonate_test

import (
"context"
"log"

admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
"google.golang.org/api/secretmanager/v1"
"google.golang.org/api/transport"
)

func ExampleCredentialsTokenSource_serviceAccount() {
ctx := context.Background()

// Base credentials sourced from ADC or provided client options.
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
// Optionally supply delegates.
Delegates: []string{"bar@project-id.iam.gserviceaccount.com"},
})
if err != nil {
log.Fatal(err)
}

// Pass an impersonated credential to any function that takes client
// options.
client, err := secretmanager.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Projects.Secrets.Get("...")
}

func ExampleCredentialsTokenSource_adminUser() {
ctx := context.Background()

// Base credentials sourced from ADC or provided client options.
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
// Optionally supply delegates.
Delegates: []string{"bar@project-id.iam.gserviceaccount.com"},
// Specify user to impersonate
Subject: "admin@example.com",
})
if err != nil {
log.Fatal(err)
}

// Pass an impersonated credential to any function that takes client
// options.
client, err := admin.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Groups.Delete("...")
}

func ExampleIDTokenSource() {
ctx := context.Background()

// Base credentials sourced from ADC or provided client options.
ts, err := impersonate.IDTokenSource(ctx, impersonate.IDTokenConfig{
Audience: "http://example.com/",
TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
IncludeEmail: true,
// Optionally supply delegates.
Delegates: []string{"bar@project-id.iam.gserviceaccount.com"},
})
if err != nil {
log.Fatal(err)
}

// Pass an impersonated credential to any function that takes client
// options.
client, _, err := transport.NewHTTPClient(ctx, option.WithTokenSource(ts))
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Get("http://example.com/")
}
129 changes: 129 additions & 0 deletions impersonate/idtoken.go
@@ -0,0 +1,129 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package impersonate

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"time"

"golang.org/x/oauth2"
"google.golang.org/api/option"
htransport "google.golang.org/api/transport/http"
)

// IDTokenConfig for generating an impersonated ID token.
type IDTokenConfig struct {
// Audience is the `aud` field for the token, such as an API endpoint the
// token will grant access to. Required.
Audience string
// TargetPrincipal is the email address of the service account to
// impersonate. Required.
TargetPrincipal string
// IncludeEmail includes the service account's email in the token. The
// resulting token will include both an `email` and `email_verified`
// claim.
IncludeEmail bool
// Delegates are the service account email addresses in a delegation chain.
// Each service account must be granted roles/iam.serviceAccountTokenCreator
// on the next service account in the chain. Optional.
Delegates []string
}

// IDTokenSource creates an impersonated TokenSource that returns ID tokens
// configured with the provided config and using credentials loaded from
// Application Default Credentials as the base credentials. The tokens provided
// by the source are valid for one hour and are automatically refreshed.
func IDTokenSource(ctx context.Context, config IDTokenConfig, opts ...option.ClientOption) (oauth2.TokenSource, error) {
if config.Audience == "" {
return nil, fmt.Errorf("impersonate: an audience must be provided")
}
if config.TargetPrincipal == "" {
return nil, fmt.Errorf("impersonate: a target service account must be provided")
}

clientOpts := append(defaultClientOptions(), opts...)
client, _, err := htransport.NewClient(ctx, clientOpts...)
if err != nil {
return nil, err
}

its := impersonatedIDTokenSource{
client: client,
targetPrincipal: config.TargetPrincipal,
audience: config.Audience,
includeEmail: config.IncludeEmail,
}
for _, v := range config.Delegates {
its.delegates = append(its.delegates, formatIAMServiceAccountName(v))
}
return oauth2.ReuseTokenSource(nil, its), nil
tbpg marked this conversation as resolved.
Show resolved Hide resolved
}

type generateIDTokenRequest struct {
Audience string `json:"audience"`
IncludeEmail bool `json:"includeEmail"`
Delegates []string `json:"delegates,omitempty"`
}

type generateIDTokenResponse struct {
Token string `json:"token"`
}

type impersonatedIDTokenSource struct {
client *http.Client

targetPrincipal string
audience string
includeEmail bool
delegates []string
}

func (i impersonatedIDTokenSource) Token() (*oauth2.Token, error) {
now := time.Now()
genIDTokenReq := generateIDTokenRequest{
Audience: i.audience,
IncludeEmail: i.includeEmail,
Delegates: i.delegates,
}
bodyBytes, err := json.Marshal(genIDTokenReq)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err)
}

url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentailsEndpoint, formatIAMServiceAccountName(i.targetPrincipal))
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("impersonate: unable to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := i.client.Do(req)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to generate ID token: %v", err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
codyoss marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("impersonate: unable to read body: %v", err)
}
if c := resp.StatusCode; c < 200 || c > 299 {
return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
}

var generateIDTokenResp generateIDTokenResponse
if err := json.Unmarshal(body, &generateIDTokenResp); err != nil {
return nil, fmt.Errorf("impersonate: unable to parse response: %v", err)
}
return &oauth2.Token{
AccessToken: generateIDTokenResp.Token,
// Generated ID tokens are good for one hour.
Expiry: now.Add(1 * time.Hour),
codyoss marked this conversation as resolved.
Show resolved Hide resolved
}, nil
}
83 changes: 83 additions & 0 deletions impersonate/idtoken_test.go
@@ -0,0 +1,83 @@
// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package impersonate

import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"
"testing"

"google.golang.org/api/option"
)

func TestIDTokenSource(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
aud string
targetPrincipal string
wantErr bool
}{
{
name: "missing aud",
targetPrincipal: "foo@project-id.iam.gserviceaccount.com",
wantErr: true,
},
{
name: "missing targetPrincipal",
aud: "http://example.com/",
wantErr: true,
},
{
name: "works",
aud: "http://example.com/",
targetPrincipal: "foo@project-id.iam.gserviceaccount.com",
wantErr: false,
},
}

for _, tt := range tests {
name := tt.name
t.Run(name, func(t *testing.T) {
idTok := "id-token"
client := &http.Client{
Transport: RoundTripFn(func(req *http.Request) *http.Response {
resp := generateIDTokenResponse{
Token: idTok,
}
b, err := json.Marshal(&resp)
if err != nil {
t.Fatalf("unable to marshal response: %v", err)
}
return &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewReader(b)),
Header: make(http.Header),
}
}),
}
ts, err := IDTokenSource(ctx, IDTokenConfig{
Audience: tt.aud,
TargetPrincipal: tt.targetPrincipal,
}, option.WithHTTPClient(client))
if tt.wantErr && err != nil {
return
}
if err != nil {
t.Fatal(err)
}
tok, err := ts.Token()
if err != nil {
t.Fatal(err)
}
if tok.AccessToken != idTok {
t.Fatalf("got %q, want %q", tok.AccessToken, idTok)
}
})
}
}