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

Add support for rotating root keys #53

Merged
merged 8 commits into from
Nov 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ module github.com/hashicorp/vault-plugin-secrets-gcp
go 1.12

require (
github.com/golang/mock v1.2.0 // indirect
github.com/hashicorp/errwrap v1.0.0
github.com/hashicorp/go-cleanhttp v0.5.1
github.com/hashicorp/go-gcp-common v0.5.0
Expand All @@ -16,6 +15,4 @@ require (
github.com/mitchellh/mapstructure v1.1.2
golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a
google.golang.org/api v0.3.2
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/vault v1.3.0 h1:MaMv38qb6DNH1uuNLHaebioueemmCrftQdOiUnlNrPU=
github.com/hashicorp/vault-plugin-auth-gcp v0.5.1 h1:8DR00s+Wmc21i3sfzvsqW88VMdf6NI2ue+onGoHshww=
github.com/hashicorp/vault-plugin-auth-gcp v0.5.1/go.mod h1:eLj92eX8MPI4vY1jaazVLF2sVbSAJ3LRHLRhF/pUmlI=
github.com/hashicorp/vault/api v1.0.1 h1:YQI4SgOlkmbEKZI8ZClo6fm9oXlBHJUlrbEtFiRPrng=
Expand All @@ -99,6 +100,7 @@ github.com/hashicorp/vault/api v1.0.5-0.20190814205728-e9c5cd8aca98 h1:LUVHA+Z7z
github.com/hashicorp/vault/api v1.0.5-0.20190814205728-e9c5cd8aca98/go.mod h1:t4IAg1Is4bLUtTq8cGgeUh0I8oDRBXPk2bM1Jvg/nWA=
github.com/hashicorp/vault/sdk v0.1.8 h1:pfF3KwA1yPlfpmcumNsFM4uo91WMasX5gTuIkItu9r0=
github.com/hashicorp/vault/sdk v0.1.8/go.mod h1:tHZfc6St71twLizWNHvnnbiGFo1aq0eD2jGPLtP8kAU=
github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0sMLy8=
github.com/hashicorp/vault/sdk v0.1.14-0.20190814205504-1cad00d1133b h1:uC3aN7xIG8gPNm9cbNY05OJ44cYfAv5Rn+QLSBsFq1s=
github.com/hashicorp/vault/sdk v0.1.14-0.20190814205504-1cad00d1133b/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
Expand Down
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
133 changes: 133 additions & 0 deletions plugin/path_config_rotate_root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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 - this " +
"endpoint only works with user-provided JSON credentials explicitly " +
"provided via the config/ endpoint")
}

// 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: keyAlgorithmRSA2k,
PrivateKeyType: privateKeyTypeJson,
}).
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)
}

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

// 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(fmt.Sprintf(
"failed to delete old service account key (%q) - the new service "+
"account key (%q) is active, but the old one still exists: {{err}}",
creds.PrivateKeyId, newCreds.PrivateKeyId), err)
}

// 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.
`
198 changes: 198 additions & 0 deletions plugin/path_config_rotate_root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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()

if testing.Short() {
t.Skip("skipping integration test (short)")
}

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)
kalafut marked this conversation as resolved.
Show resolved Hide resolved
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: keyAlgorithmRSA2k,
PrivateKeyType: privateKeyTypeJson,
}).
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")
}
})
}
Loading