diff --git a/plugins/secrets/vault/opts.go b/plugins/secrets/vault/opts.go new file mode 100644 index 000000000..e2833aaa3 --- /dev/null +++ b/plugins/secrets/vault/opts.go @@ -0,0 +1,26 @@ +// Copyright 2018 Drone.IO Inc +// Use of this software is governed by the Drone Enterpise License +// that can be found in the LICENSE file. + +package vault + +import "time" + +// Opts sets custom options for the vault client. +type Opts func(v *vault) + +// WithTTL returns an options that sets a TTL used to +// refresh periodic tokens. +func WithTTL(d time.Duration) Opts { + return func(v *vault) { + v.ttl = d + } +} + +// WithRenewal returns an options that sets the renewal +// period used to refresh periodic tokens +func WithRenewal(d time.Duration) Opts { + return func(v *vault) { + v.renew = d + } +} diff --git a/plugins/secrets/vault/opts_test.go b/plugins/secrets/vault/opts_test.go new file mode 100644 index 000000000..217a98892 --- /dev/null +++ b/plugins/secrets/vault/opts_test.go @@ -0,0 +1,28 @@ +// Copyright 2018 Drone.IO Inc +// Use of this software is governed by the Drone Enterpise License +// that can be found in the LICENSE file. + +package vault + +import ( + "testing" + "time" +) + +func TestWithTTL(t *testing.T) { + v := new(vault) + opt := WithTTL(time.Hour) + opt(v) + if got, want := v.ttl, time.Hour; got != want { + t.Errorf("Want ttl %v, got %v", want, got) + } +} + +func TestWithRenewal(t *testing.T) { + v := new(vault) + opt := WithRenewal(time.Hour) + opt(v) + if got, want := v.renew, time.Hour; got != want { + t.Errorf("Want renewal %v, got %v", want, got) + } +} diff --git a/plugins/secrets/vault/vault.go b/plugins/secrets/vault/vault.go new file mode 100644 index 000000000..407858381 --- /dev/null +++ b/plugins/secrets/vault/vault.go @@ -0,0 +1,219 @@ +// Copyright 2018 Drone.IO Inc +// Use of this software is governed by the Drone Enterpise License +// that can be found in the LICENSE file. + +package vault + +import ( + "path" + "strings" + "time" + + "github.com/Sirupsen/logrus" + "github.com/drone/drone/extras/secrets" + "github.com/drone/drone/model" + + "github.com/hashicorp/vault/api" + "gopkg.in/yaml.v2" +) + +// yaml configuration representation +// +// secrets: +// docker_username: +// file: path/to/docker/username +// docker_password: +// file: path/to/docker/password +// +type vaultConfig struct { + Secrets map[string]struct { + Path string + File string + Vault string + } +} + +type vault struct { + store model.ConfigStore + client *api.Client + ttl time.Duration + renew time.Duration + done chan struct{} +} + +// New returns a new store with secrets loaded from vault. +func New(store model.ConfigStore, opts ...Opts) (secrets.Plugin, error) { + client, err := api.NewClient(nil) + if err != nil { + return nil, err + } + v := &vault{ + store: store, + client: client, + } + for _, opt := range opts { + opt(v) + } + v.start() // start the refresh process. + return v, nil +} + +func (v *vault) SecretListBuild(repo *model.Repo, build *model.Build) ([]*model.Secret, error) { + return v.list(repo, build) +} + +func (v *vault) list(repo *model.Repo, build *model.Build) ([]*model.Secret, error) { + conf, err := v.store.ConfigLoad(build.ConfigID) + if err != nil { + return nil, err + } + var ( + in = []byte(conf.Data) + out = new(vaultConfig) + + secrets []*model.Secret + ) + err = yaml.Unmarshal(in, out) + if err != nil { + return nil, err + } + for key, val := range out.Secrets { + var path string + switch { + case val.Path != "": + path = val.Path + case val.File != "": + path = val.File + case val.Vault != "": + path = val.Vault + } + + if path == "" { + continue + } + + vaultSecret, err := v.get(path) + if err != nil { + return nil, err + } + if vaultSecret == nil { + continue + } + if !vaultSecret.Match(repo.FullName) { + continue + } + + secrets = append(secrets, &model.Secret{ + Name: key, + Value: vaultSecret.Value, + Events: vaultSecret.Event, + Images: vaultSecret.Image, + }) + } + return secrets, nil +} + +func (v *vault) get(path string) (*vaultSecret, error) { + secret, err := v.client.Logical().Read(path) + if err != nil { + return nil, err + } + if secret == nil || secret.Data == nil { + return nil, nil + } + return parseVaultSecret(secret.Data), nil +} + +// start starts the renewal loop. +func (v *vault) start() { + if v.renew == 0 || v.ttl == 0 { + logrus.Debugf("vault: token renewal disabled") + return + } + if v.done != nil { + close(v.done) + } + logrus.Debugf("vault: token renewal enabled: renew every %v", v.renew) + v.done = make(chan struct{}) + if v.renew != 0 { + go v.renewLoop() + } +} + +// stop stops the renewal loop. +func (v *vault) stop() { + close(v.done) +} + +func (v *vault) renewLoop() { + for { + select { + case <-time.After(v.renew): + incr := int(v.ttl / time.Second) + + logrus.Debugf("vault: refreshing token: increment %v", v.ttl) + _, err := v.client.Auth().Token().RenewSelf(incr) + if err != nil { + logrus.Errorf("vault: refreshing token failed: %s", err) + } else { + logrus.Debugf("vault: refreshing token succeeded") + } + case <-v.done: + return + } + } +} + +type vaultSecret struct { + Value string + Image []string + Event []string + Repo []string +} + +func parseVaultSecret(data map[string]interface{}) *vaultSecret { + secret := new(vaultSecret) + + if vvalue, ok := data["value"]; ok { + if svalue, ok := vvalue.(string); ok { + secret.Value = svalue + } + } + if vimage, ok := data["image"]; ok { + if simage, ok := vimage.(string); ok { + secret.Image = strings.Split(simage, ",") + } + } + if vevent, ok := data["event"]; ok { + if sevent, ok := vevent.(string); ok { + secret.Event = strings.Split(sevent, ",") + } + } + if vrepo, ok := data["repo"]; ok { + if srepo, ok := vrepo.(string); ok { + secret.Repo = strings.Split(srepo, ",") + } + } + if secret.Event == nil { + secret.Event = []string{} + } + if secret.Image == nil { + secret.Image = []string{} + } + if secret.Repo == nil { + secret.Repo = []string{} + } + return secret +} + +func (v *vaultSecret) Match(name string) bool { + if len(v.Repo) == 0 { + return true + } + for _, pattern := range v.Repo { + if ok, _ := path.Match(pattern, name); ok { + return true + } + } + return false +} diff --git a/plugins/secrets/vault/vault_test.go b/plugins/secrets/vault/vault_test.go new file mode 100644 index 000000000..fd93853b3 --- /dev/null +++ b/plugins/secrets/vault/vault_test.go @@ -0,0 +1,99 @@ +// Copyright 2018 Drone.IO Inc +// Use of this software is governed by the Drone Enterpise License +// that can be found in the LICENSE file. + +package vault + +import ( + "os" + "reflect" + "testing" + + "github.com/hashicorp/vault/api" + "github.com/kr/pretty" +) + +// Use the following snippet to spin up a local vault +// server for integration testing: +// +// docker run --cap-add=IPC_LOCK -e 'VAULT_DEV_ROOT_TOKEN_ID=dummy' -p 8200:8200 vault +// export VAULT_ADDR=http://127.0.0.1:8200 +// export VAULT_TOKEN=dummy + +func TestVaultGet(t *testing.T) { + if os.Getenv("VAULT_TOKEN") == "" { + t.SkipNow() + return + } + + client, err := api.NewClient(nil) + if err != nil { + t.Error(err) + return + } + + _, err = client.Logical().Write("secret/testing/drone/a", map[string]interface{}{ + "value": "hello", + "image": "golang", + "event": "push,pull_request", + "repo": "octocat/hello-world,github/*", + }) + if err != nil { + t.Error(err) + return + } + + plugin := vault{client: client} + secret, err := plugin.get("secret/testing/drone/a") + if err != nil { + t.Error(err) + return + } + + if got, want := secret.Value, "hello"; got != want { + t.Errorf("Expect secret value %s, got %s", want, got) + } + + secret, err = plugin.get("secret/testing/drone/404") + if err != nil { + t.Errorf("Expect silent failure when secret does not exist, got %s", err) + } + if secret != nil { + t.Errorf("Expect nil secret when path does not exist") + } +} + +func TestVaultSecretParse(t *testing.T) { + data := map[string]interface{}{ + "value": "password", + "event": "push,tag", + "image": "plugins/s3,plugins/ec2", + "repo": "octocat/hello-world,github/*", + } + want := vaultSecret{ + Value: "password", + Event: []string{"push", "tag"}, + Image: []string{"plugins/s3", "plugins/ec2"}, + Repo: []string{"octocat/hello-world", "github/*"}, + } + got := parseVaultSecret(data) + if !reflect.DeepEqual(want, *got) { + t.Errorf("Failed read Secret.Data") + pretty.Fdiff(os.Stderr, want, got) + } +} + +func TestVaultSecretMatch(t *testing.T) { + secret := vaultSecret{ + Repo: []string{"octocat/hello-world", "github/*"}, + } + if secret.Match("octocat/*") { + t.Errorf("Expect octocat/* does not match") + } + if !secret.Match("octocat/hello-world") { + t.Errorf("Expect octocat/hello-world does match") + } + if !secret.Match("github/hello-world") { + t.Errorf("Expect github/hello-world does match wildcard") + } +}