Skip to content

Commit

Permalink
Add config/rotate-root
Browse files Browse the repository at this point in the history
This path rotates the underlying GCP service account key. It
specifically generates a new key and revokes the old one.
  • Loading branch information
sethvargo committed Nov 14, 2019
1 parent ac92312 commit bf03529
Show file tree
Hide file tree
Showing 3 changed files with 323 additions and 0 deletions.
1 change: 1 addition & 0 deletions plugin/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func Backend() *backend {
pathsRoleSet(b),
[]*framework.Path{
pathConfig(b),
pathConfigRotateRoot(b),
pathSecretAccessToken(b),
pathSecretServiceAccountKey(b),
},
Expand Down
128 changes: 128 additions & 0 deletions plugin/path_config_rotate_root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package gcpsecrets

import (
"context"
"encoding/base64"
"fmt"

"github.com/hashicorp/errwrap"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"google.golang.org/api/iam/v1"
)

func pathConfigRotateRoot(b *backend) *framework.Path {
return &framework.Path{
Pattern: "config/rotate-root",

Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathConfigRotateRootWrite,
},
},

HelpSynopsis: pathConfigRotateRootHelpSyn,
HelpDescription: pathConfigRotateRootHelpDesc,
}
}

func (b *backend) pathConfigRotateRootWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
// Get the current configuration
cfg, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("no configuration")
}
if cfg.CredentialsRaw == "" {
return nil, fmt.Errorf("configuration does not have credentials")
}

// Parse the credential JSON to extract the email (we need it for the API
// call)
creds, err := gcputil.Credentials(cfg.CredentialsRaw)
if err != nil {
return nil, errwrap.Wrapf("credentials are invalid: {{err}}", err)
}

// Generate a new service account key
iamAdmin, err := b.IAMAdminClient(req.Storage)
if err != nil {
return nil, errwrap.Wrapf("failed to create iam client: {{err}}", err)
}

saName := "projects/-/serviceAccounts/" + creds.ClientEmail
newKey, err := iamAdmin.Projects.ServiceAccounts.Keys.
Create(saName, &iam.CreateServiceAccountKeyRequest{
KeyAlgorithm: "KEY_ALG_RSA_2048",
PrivateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
}).
Context(ctx).
Do()
if err != nil {
return nil, errwrap.Wrapf("failed to create new key: {{err}}", err)
}

// Base64-decode the private key data (it's the JSON file)
newCredsJSON, err := base64.StdEncoding.DecodeString(newKey.PrivateKeyData)
if err != nil {
return nil, errwrap.Wrapf("failed to decode credentials: {{err}}", err)
}

// Verify creds are valid
newCreds, err := gcputil.Credentials(string(newCredsJSON))
if err != nil {
return nil, errwrap.Wrapf("api returned invalid credentials: {{err}}", err)
}

// Update the configuration
cfg.CredentialsRaw = string(newCredsJSON)
entry, err := logical.StorageEntryJSON("config", cfg)
if err != nil {
return nil, errwrap.Wrapf("failed to generate new configuration: {{err}}", err)
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, errwrap.Wrapf("failed to save new configuration: {{err}}", err)
}

// Delete the old service account key
oldKeyName := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s",
creds.ProjectId,
creds.ClientEmail,
creds.PrivateKeyId)
if _, err := iamAdmin.Projects.ServiceAccounts.Keys.
Delete(oldKeyName).
Context(ctx).
Do(); err != nil {
return nil, errwrap.Wrapf("failed to delete old service account key: {{err}}", err)
}

// Clear caches to pick up the new credentials
b.ClearCaches()

// We did it!
return &logical.Response{
Data: map[string]interface{}{
"private_key_id": newCreds.PrivateKeyId,
},
}, nil
}

const pathConfigRotateRootHelpSyn = `
Request to rotate the GCP credentials used by Vault
`

const pathConfigRotateRootHelpDesc = `
This path attempts to rotate the GCP service account credentials used by Vault
for this mount. It does this by generating a new key for the service account,
replacing the internal value, and then scheduling a deletion of the old service
account key. Note that it does not create a new service account, only a new
version of the service account key.
This path is only valid if Vault has been configured to use GCP credentials via
the config/ endpoint where "credentials" were specified. Additionally, the
provided service account must have permissions to create and delete service
account keys.
`
194 changes: 194 additions & 0 deletions plugin/path_config_rotate_root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package gcpsecrets

import (
"context"
"encoding/base64"
"fmt"
"io/ioutil"
"os"
"strings"
"testing"
"time"

"github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/vault/sdk/logical"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/iam/v1"
)

func TestConfigRotateRootUpdate(t *testing.T) {
t.Parallel()

t.Run("no_configuration", func(t *testing.T) {
t.Parallel()

b, storage := getTestBackend(t)
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err == nil {
t.Fatal("expected error")
}
if exp, act := "no configuration", err.Error(); !strings.Contains(act, exp) {
t.Errorf("expected %q to contain %q", act, exp)
}
})

t.Run("config_with_no_credentials", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
b, storage := getTestBackend(t)

entry, err := logical.StorageEntryJSON("config", &config{
TTL: 5 * time.Minute,
})
if err != nil {
t.Fatal(err)
}
if err := storage.Put(ctx, entry); err != nil {
t.Fatal(err)
}

_, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err == nil {
t.Fatal("expected error")
}
if exp, act := "does not have credentials", err.Error(); !strings.Contains(act, exp) {
t.Errorf("expected %q to contain %q", act, exp)
}
})

t.Run("config_with_invalid_credentials", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
b, storage := getTestBackend(t)

entry, err := logical.StorageEntryJSON("config", &config{
CredentialsRaw: "baconbaconbacon",
})
if err != nil {
t.Fatal(err)
}
if err := storage.Put(ctx, entry); err != nil {
t.Fatal(err)
}

_, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err == nil {
t.Fatal("expected error")
}
if exp, act := "credentials are invalid", err.Error(); !strings.Contains(act, exp) {
t.Errorf("expected %q to contain %q", act, exp)
}
})

t.Run("rotate", func(t *testing.T) {
t.Parallel()

ctx := context.Background()
b, storage := getTestBackend(t)

// Get user-supplied credentials
credsPath := os.Getenv("GOOGLE_CREDENTIALS")
credsBytes, err := ioutil.ReadFile(credsPath)
if err != nil {
t.Fatal(err)
}
creds, err := google.CredentialsFromJSON(ctx, credsBytes, iam.CloudPlatformScope)
if err != nil {
t.Fatal(err)
}
parsedCreds, err := gcputil.Credentials(string(credsBytes))
if err != nil {
t.Fatal(err)
}

// Create http client
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, cleanhttp.DefaultClient())
client := oauth2.NewClient(clientCtx, creds.TokenSource)

// Create IAM client
iamAdmin, err := iam.New(client)
if err != nil {
t.Fatal(err)
}

// Create a new key, since this endpoint revokes the old key
saName := "projects/-/serviceAccounts/" + parsedCreds.ClientEmail
newKey, err := iamAdmin.Projects.ServiceAccounts.Keys.
Create(saName, &iam.CreateServiceAccountKeyRequest{
KeyAlgorithm: "KEY_ALG_RSA_2048",
PrivateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
}).
Context(ctx).
Do()
if err != nil {
t.Fatal(err)
}

// Base64-decode the private key data (it's the JSON file)
newCredsJSON, err := base64.StdEncoding.DecodeString(newKey.PrivateKeyData)
if err != nil {
t.Fatal(err)
}

// Parse new creds
newCreds, err := gcputil.Credentials(string(newCredsJSON))
if err != nil {
t.Fatal(err)
}

// If we made it this far, schedule a cleanup of the new key
defer func() {
newKeyName := fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s",
newCreds.ProjectId,
newCreds.ClientEmail,
newCreds.PrivateKeyId)
iamAdmin.Projects.ServiceAccounts.Keys.Delete(newKeyName)
}()

// Set config to the key
entry, err := logical.StorageEntryJSON("config", &config{
CredentialsRaw: string(newCredsJSON),
})
if err != nil {
t.Fatal(err)
}
if err := storage.Put(ctx, entry); err != nil {
t.Fatal(err)
}

// Rotate the key
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.UpdateOperation,
Path: "config/rotate-root",
Storage: storage,
})
if err != nil {
t.Fatal(err)
}

privateKeyId := resp.Data["private_key_id"]
if privateKeyId == "" {
t.Errorf("missing private_key_id")
}

if privateKeyId == newCreds.PrivateKeyId {
t.Errorf("creds were not rotated")
}
})
}

0 comments on commit bf03529

Please sign in to comment.