Skip to content
This repository was archived by the owner on Jun 27, 2021. It is now read-only.

Commit 2e482b8

Browse files
psalaberria002DeviaVir
authored andcommitted
Make it possible to run as a service account (#9)
* Make it possible to run as service account https://developers.google.com/admin-sdk/directory/v1/guides/delegation * Delete extra slashes * Update readme * Return error when impersonated user not set * Fix credentials example * Fix issues with case insensitive import collision * Add oauth_scopes to the provider config * Add initial provider and config tests * Add test for credentials and impersonated user * Add make test and fix fmt issues * Rollback repo path * Use d for integers * Revert the revert * Run tests in Travis
1 parent 9aec69b commit 2e482b8

File tree

12 files changed

+387
-30
lines changed

12 files changed

+387
-30
lines changed

.travis.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ install:
1616

1717
script:
1818
- 'if [ "${TRAVIS_BUILD_DIR}" != "${GOPATH}/src/github.com/DeviaVir/terraform-provider-gsuite" ]; then ln -s "${TRAVIS_BUILD_DIR}" "${GOPATH}/src/github.com/DeviaVir/terraform-provider-gsuite"; fi'
19+
- make test
1920
- make

Gopkg.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ dev:
9595
-tags "${GOTAGS}" \
9696
-o "${PLUGIN_PATH}/terraform-provider-gsuite"
9797

98+
# test runs all tests
99+
test:
100+
go test -i $(TEST) || exit 1
101+
echo $(TEST) | \
102+
xargs -t -n4 go test $(TESTARGS) -timeout=30s -parallel=4
103+
98104
# dist builds the binaries and then signs and packages them for distribution
99105
dist:
100106
ifndef GPG_KEY

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,57 @@
22

33
This is a terraform provider for managing GSuite (Admin SDK) resources on Google
44

5-
## Setup
5+
## Authentication
6+
7+
There are two possible authentication mechanisms for using this provider.
8+
Using a service account, or a personal admin account. The latter requires
9+
user interaction, whereas a service account could be used in an automated
10+
workflow.
11+
12+
See the necessary oauth scopes both for service accounts and users below:
13+
- https://www.googleapis.com/auth/admin.directory.customer
14+
- https://www.googleapis.com/auth/admin.directory.group
15+
- https://www.googleapis.com/auth/admin.directory.orgunit
16+
- https://www.googleapis.com/auth/admin.directory.user
17+
- https://www.googleapis.com/auth/admin.directory.userschema
18+
- https://www.googleapis.com/auth/userinfo.email
19+
20+
You could also provide the minimal set of scopes using the
21+
`oauth_scopes` variable in the provider configuration.
22+
23+
```
24+
provider "gsuite" {
25+
oauth_scopes = [
26+
"https://www.googleapis.com/auth/admin.directory.group"
27+
]
28+
}
29+
```
30+
31+
### Using a service account
32+
33+
Service accounts are great for automated workflows.
34+
35+
Only users with access to the Admin APIs can access the Admin SDK Directory API,
36+
therefore the service account needs to impersonate one of those users
37+
to access the Admin SDK Directory API.
38+
39+
Follow the instruction at
40+
https://developers.google.com/admin-sdk/directory/v1/guides/delegation.
41+
42+
Add `credentials` and `impersonated_user_email` when initializing the provider.
43+
```
44+
provider "gsuite" {
45+
credentials = "/full/path/service-account.json"
46+
impersonated_user_email = "admin@xxx.com"
47+
}
48+
```
49+
50+
Credentials can also be provided via the following environment variables:
51+
- GOOGLE_CREDENTIALS
52+
- GOOGLE_CLOUD_KEYFILE_JSON
53+
- GCLOUD_KEYFILE_JSON
54+
55+
### Using a personal administrator account
656

757
In order to use the Admin SDK with a project, we will first need to create
858
credentials for that project, you can do so here:

gsuite/config.go

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ import (
1111
"github.com/pkg/errors"
1212
"golang.org/x/oauth2/google"
1313
directory "google.golang.org/api/admin/directory/v1"
14+
"github.com/hashicorp/terraform/helper/pathorcontents"
15+
"golang.org/x/oauth2/jwt"
16+
"net/http"
17+
"strings"
18+
"encoding/json"
1419
)
1520

16-
var oauthScopes = []string{
21+
var defaultOauthScopes = []string{
1722
directory.AdminDirectoryCustomerScope,
1823
directory.AdminDirectoryGroupScope,
1924
directory.AdminDirectoryGroupMemberScope,
@@ -26,16 +31,68 @@ var oauthScopes = []string{
2631

2732
// Config is the structure used to instantiate the GSuite provider.
2833
type Config struct {
34+
Credentials string
35+
// Only users with access to the Admin APIs can access the Admin SDK Directory API,
36+
// therefore the service account needs to impersonate one of those users to access the Admin SDK Directory API.
37+
// See https://developers.google.com/admin-sdk/directory/v1/guides/delegation
38+
ImpersonatedUserEmail string
39+
40+
OauthScopes []string
41+
2942
directory *directory.Service
3043
}
3144

3245
// loadAndValidate loads the application default credentials from the
3346
// environment and creates a client for communicating with Google APIs.
3447
func (c *Config) loadAndValidate() error {
35-
log.Printf("[INFO] authenticating with local client")
36-
client, err := google.DefaultClient(context.Background(), oauthScopes...)
37-
if err != nil {
38-
return errors.Wrap(err, "failed to create client")
48+
var account accountFile
49+
50+
oauthScopes := c.OauthScopes
51+
52+
53+
54+
var client *http.Client
55+
if c.Credentials != "" {
56+
if c.ImpersonatedUserEmail == "" {
57+
return fmt.Errorf("required field missing: impersonated_user_email")
58+
}
59+
60+
contents, _, err := pathorcontents.Read(c.Credentials)
61+
if err != nil {
62+
return fmt.Errorf("Error loading credentials: %s", err)
63+
}
64+
65+
// Assume account_file is a JSON string
66+
if err := parseJSON(&account, contents); err != nil {
67+
return fmt.Errorf("Error parsing credentials '%s': %s", contents, err)
68+
}
69+
70+
// Get the token for use in our requests
71+
log.Printf("[INFO] Requesting Google token...")
72+
log.Printf("[INFO] -- Email: %s", account.ClientEmail)
73+
log.Printf("[INFO] -- Scopes: %s", oauthScopes)
74+
log.Printf("[INFO] -- Private Key Length: %d", len(account.PrivateKey))
75+
76+
conf := jwt.Config{
77+
Email: account.ClientEmail,
78+
PrivateKey: []byte(account.PrivateKey),
79+
Scopes: oauthScopes,
80+
TokenURL: "https://accounts.google.com/o/oauth2/token",
81+
}
82+
83+
conf.Subject = c.ImpersonatedUserEmail
84+
85+
// Initiate an http.Client. The following GET request will be
86+
// authorized and authenticated on the behalf of
87+
// your service account.
88+
client = conf.Client(context.Background())
89+
} else {
90+
log.Printf("[INFO] Authenticating using DefaultClient")
91+
err := error(nil)
92+
client, err = google.DefaultClient(context.Background(), oauthScopes...)
93+
if err != nil {
94+
return errors.Wrap(err, "failed to create client")
95+
}
3996
}
4097

4198
// Use a custom user-agent string. This helps google with analytics and it's
@@ -54,3 +111,18 @@ func (c *Config) loadAndValidate() error {
54111

55112
return nil
56113
}
114+
115+
// accountFile represents the structure of the account file JSON file.
116+
type accountFile struct {
117+
PrivateKeyId string `json:"private_key_id"`
118+
PrivateKey string `json:"private_key"`
119+
ClientEmail string `json:"client_email"`
120+
ClientId string `json:"client_id"`
121+
}
122+
123+
func parseJSON(result interface{}, contents string) error {
124+
r := strings.NewReader(contents)
125+
dec := json.NewDecoder(r)
126+
127+
return dec.Decode(result)
128+
}

gsuite/config_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package gsuite
2+
3+
import (
4+
"io/ioutil"
5+
"testing"
6+
)
7+
8+
const testFakeCredentialsPath = "./test-fixtures/fake_account.json"
9+
10+
func TestConfigLoadAndValidate_accountFilePath(t *testing.T) {
11+
config := Config{
12+
Credentials: testFakeCredentialsPath,
13+
ImpersonatedUserEmail: "xxx@xxx.xom",
14+
}
15+
16+
err := config.loadAndValidate()
17+
if err != nil {
18+
t.Fatalf("error: %v", err)
19+
}
20+
}
21+
22+
func TestConfigLoadAndValidate_accountFileJSON(t *testing.T) {
23+
contents, err := ioutil.ReadFile(testFakeCredentialsPath)
24+
if err != nil {
25+
t.Fatalf("error: %v", err)
26+
}
27+
config := Config{
28+
Credentials: string(contents),
29+
ImpersonatedUserEmail: "xxx@xxx.xom",
30+
}
31+
32+
err = config.loadAndValidate()
33+
if err != nil {
34+
t.Fatalf("error: %v", err)
35+
}
36+
}
37+
38+
func TestConfigLoadAndValidate_accountFileJSONInvalid(t *testing.T) {
39+
config := Config{
40+
Credentials: "{this is not json}",
41+
}
42+
43+
if config.loadAndValidate() == nil {
44+
t.Fatalf("expected error, but got nil")
45+
}
46+
}
47+
48+
func TestConfigLoadAndValidate_noImpersonatedEmail(t *testing.T) {
49+
// ImpersonatedUserEmail empty string when credentials set
50+
config := Config{
51+
Credentials: testFakeCredentialsPath,
52+
ImpersonatedUserEmail: "",
53+
}
54+
55+
err := config.loadAndValidate()
56+
if err == nil {
57+
t.Fatalf("error: %v", err)
58+
}
59+
if err.Error() != "required field missing: impersonated_user_email" {
60+
t.Fatalf("error: %v", err)
61+
}
62+
63+
// ImpersonatedUserEmail not provided when credentials set
64+
config = Config{
65+
Credentials: testFakeCredentialsPath,
66+
}
67+
68+
err = config.loadAndValidate()
69+
if err == nil {
70+
t.Fatalf("error: %v", err)
71+
}
72+
if err.Error() != "required field missing: impersonated_user_email" {
73+
t.Fatalf("error: %v", err)
74+
}
75+
}

gsuite/provider.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import (
55
"time"
66

77
"github.com/hashicorp/terraform/helper/schema"
8+
"os"
9+
"encoding/json"
10+
"fmt"
811
"github.com/pkg/errors"
12+
"log"
913
)
1014

1115
var (
@@ -16,6 +20,27 @@ var (
1620
// Provider returns the actual provider instance.
1721
func Provider() *schema.Provider {
1822
return &schema.Provider{
23+
Schema: map[string]*schema.Schema{
24+
"credentials": &schema.Schema{
25+
Type: schema.TypeString,
26+
Optional: true,
27+
DefaultFunc: schema.MultiEnvDefaultFunc([]string{
28+
"GOOGLE_CREDENTIALS",
29+
"GOOGLE_CLOUD_KEYFILE_JSON",
30+
"GCLOUD_KEYFILE_JSON",
31+
}, nil),
32+
ValidateFunc: validateCredentials,
33+
},
34+
"impersonated_user_email": &schema.Schema{
35+
Type: schema.TypeString,
36+
Optional: true,
37+
},
38+
"oauth_scopes": &schema.Schema{
39+
Type: schema.TypeSet,
40+
Elem: &schema.Schema{Type: schema.TypeString},
41+
Optional: true,
42+
},
43+
},
1944
ResourcesMap: map[string]*schema.Resource{
2045
"gsuite_group": resourceGroup(),
2146
"gsuite_user": resourceUser(),
@@ -26,18 +51,51 @@ func Provider() *schema.Provider {
2651
}
2752
}
2853

29-
// providerConfigure configures the provider. Normally this would use schema
30-
// data from the provider, but the provider loads all its configuration from the
31-
// environment, so we just tell the config to load.
54+
func oauthScopesFromConfigOrDefault(oauthScopesSet *schema.Set) []string {
55+
oauthScopes := convertStringSet(oauthScopesSet)
56+
if len(oauthScopes) == 0 {
57+
log.Printf("[INFO] No Oauth Scopes provided. Using default oauth scopes.")
58+
oauthScopes = defaultOauthScopes
59+
}
60+
return oauthScopes
61+
}
62+
3263
func providerConfigure(d *schema.ResourceData) (interface{}, error) {
33-
var c Config
34-
if err := c.loadAndValidate(); err != nil {
64+
credentials := d.Get("credentials").(string)
65+
impersonatedUserEmail := d.Get("impersonated_user_email").(string)
66+
oauthScopes := oauthScopesFromConfigOrDefault(d.Get("oauth_scopes").(*schema.Set))
67+
config := Config{
68+
Credentials: credentials,
69+
ImpersonatedUserEmail: impersonatedUserEmail,
70+
OauthScopes: oauthScopes,
71+
}
72+
73+
if err := config.loadAndValidate(); err != nil {
3574
return nil, errors.Wrap(err, "failed to load config")
3675
}
37-
return &c, nil
76+
77+
return &config, nil
3878
}
3979

4080
// contextWithTimeout creates a new context with the global context timeout.
4181
func contextWithTimeout() (context.Context, func()) {
4282
return context.WithTimeout(context.Background(), contextTimeout)
4383
}
84+
85+
func validateCredentials(v interface{}, k string) (warnings []string, errors []error) {
86+
if v == nil || v.(string) == "" {
87+
return
88+
}
89+
creds := v.(string)
90+
// if this is a path and we can stat it, assume it's ok
91+
if _, err := os.Stat(creds); err == nil {
92+
return
93+
}
94+
var account accountFile
95+
if err := json.Unmarshal([]byte(creds), &account); err != nil {
96+
errors = append(errors,
97+
fmt.Errorf("credentials are not valid JSON '%s': %s", creds, err))
98+
}
99+
100+
return
101+
}

0 commit comments

Comments
 (0)