diff --git a/CHANGELOG.md b/CHANGELOG.md index 702607bdf..fec06ee41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - support for Cron job name in Yaml when block, by [@bradrydzewski](https://github.com/bradrydzewski). [#2628](https://github.com/drone/drone/issues/2628). - sqlite username column changed to case-insensitive, by [@bradrydzewski](https://github.com/bradrydzewski). - endpoint to purge repository from database, by [@bradrydzewski](https://github.com/bradrydzewski). +- support for per-organization secrets, by [@bradrydzewski](https://github.com/bradrydzewski). - update drone-yaml from version 1.0.6 to 1.0.8. - update drone-runtime from version 1.0.4 to 1.0.6. diff --git a/cmd/drone-server/inject_store.go b/cmd/drone-server/inject_store.go index 2d23d55b5..46906acb4 100644 --- a/cmd/drone-server/inject_store.go +++ b/cmd/drone-server/inject_store.go @@ -25,6 +25,7 @@ import ( "github.com/drone/drone/store/perm" "github.com/drone/drone/store/repos" "github.com/drone/drone/store/secret" + "github.com/drone/drone/store/secret/global" "github.com/drone/drone/store/shared/db" "github.com/drone/drone/store/shared/encrypt" "github.com/drone/drone/store/stage" @@ -47,6 +48,7 @@ var storeSet = wire.NewSet( cron.New, perm.New, secret.New, + global.New, step.New, ) diff --git a/cmd/drone-server/wire_gen.go b/cmd/drone-server/wire_gen.go index dfba4ee7d..52b795e86 100644 --- a/cmd/drone-server/wire_gen.go +++ b/cmd/drone-server/wire_gen.go @@ -24,6 +24,7 @@ import ( "github.com/drone/drone/store/cron" "github.com/drone/drone/store/perm" "github.com/drone/drone/store/secret" + "github.com/drone/drone/store/secret/global" "github.com/drone/drone/store/step" "github.com/drone/drone/trigger" cron2 "github.com/drone/drone/trigger/cron" @@ -70,8 +71,9 @@ func InitializeApplication(config2 config.Config) (application, error) { return application{}, err } secretStore := secret.New(db, encrypter) + globalSecretStore := global.New(db, encrypter) stepStore := step.New(db) - buildManager := manager.New(buildStore, configService, corePubsub, logStore, logStream, netrcService, repositoryStore, scheduler, secretStore, statusService, stageStore, stepStore, system, userStore, webhookSender) + buildManager := manager.New(buildStore, configService, corePubsub, logStore, logStream, netrcService, repositoryStore, scheduler, secretStore, globalSecretStore, statusService, stageStore, stepStore, system, userStore, webhookSender) secretService := provideSecretPlugin(config2) registryService := provideRegistryPlugin(config2) runner := provideRunner(buildManager, secretService, registryService, config2) @@ -82,7 +84,7 @@ func InitializeApplication(config2 config.Config) (application, error) { session := provideSession(userStore, config2) batcher := batch.New(db) syncer := provideSyncer(repositoryService, repositoryStore, userStore, batcher, config2) - server := api.New(buildStore, commitService, cronStore, corePubsub, hookService, logStore, coreLicense, licenseService, permStore, repositoryStore, repositoryService, scheduler, secretStore, stageStore, stepStore, statusService, session, logStream, syncer, system, triggerer, userStore, webhookSender) + server := api.New(buildStore, commitService, cronStore, corePubsub, globalSecretStore, hookService, logStore, coreLicense, licenseService, permStore, repositoryStore, repositoryService, scheduler, secretStore, stageStore, stepStore, statusService, session, logStream, syncer, system, triggerer, userStore, webhookSender) organizationService := orgs.New(client, renewer) userService := user.New(client) admissionService := provideAdmissionPlugin(client, organizationService, userService, config2) diff --git a/core/secret.go b/core/secret.go index cca79b83f..a8ea6bead 100644 --- a/core/secret.go +++ b/core/secret.go @@ -33,7 +33,9 @@ type ( Secret struct { ID int64 `json:"id,omitempty"` RepoID int64 `json:"repo_id,omitempty"` + Namespace string `json:"repo_namespace,omitempty"` Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` Data string `json:"data,omitempty"` PullRequest bool `json:"pull_request,omitempty"` PullRequestPush bool `json:"pull_request_push,omitempty"` @@ -69,6 +71,32 @@ type ( Delete(context.Context, *Secret) error } + // GlobalSecretStore manages global secrets accessible to + // all repositories in the system. + GlobalSecretStore interface { + // List returns a secret list from the datastore. + List(ctx context.Context, namespace string) ([]*Secret, error) + + // ListAll returns a secret list from the datastore + // for all namespaces. + ListAll(ctx context.Context) ([]*Secret, error) + + // Find returns a secret from the datastore. + Find(ctx context.Context, id int64) (*Secret, error) + + // FindName returns a secret from the datastore. + FindName(ctx context.Context, namespace, name string) (*Secret, error) + + // Create persists a new secret to the datastore. + Create(ctx context.Context, secret *Secret) error + + // Update persists an updated secret to the datastore. + Update(ctx context.Context, secret *Secret) error + + // Delete deletes a secret from the datastore. + Delete(ctx context.Context, secret *Secret) error + } + // SecretService provides secrets from an external service. SecretService interface { // Find returns a named secret from the global remote service. @@ -95,7 +123,9 @@ func (s *Secret) Copy() *Secret { return &Secret{ ID: s.ID, RepoID: s.RepoID, + Namespace: s.Namespace, Name: s.Name, + Type: s.Type, PullRequest: s.PullRequest, PullRequestPush: s.PullRequestPush, } diff --git a/core/secret_test.go b/core/secret_test.go index c9400844d..96df2b028 100644 --- a/core/secret_test.go +++ b/core/secret_test.go @@ -47,6 +47,8 @@ func TestSecretSafeCopy(t *testing.T) { ID: 1, RepoID: 2, Name: "docker_password", + Namespace: "octocat", + Type: "", Data: "correct-horse-battery-staple", PullRequest: true, PullRequestPush: true, @@ -61,6 +63,9 @@ func TestSecretSafeCopy(t *testing.T) { if got, want := after.Name, before.Name; got != want { t.Errorf("Want secret Name %s, got %s", want, got) } + if got, want := after.Namespace, before.Namespace; got != want { + t.Errorf("Want secret Namespace %s, got %s", want, got) + } if got, want := after.PullRequest, before.PullRequest; got != want { t.Errorf("Want secret PullRequest %v, got %v", want, got) } diff --git a/go.sum b/go.sum index 40c70bce7..b4175d4c5 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,7 @@ github.com/drone/go-login v1.0.3 h1:YmZMUoWWd3QrgmobC1DcExFjW7w2ZEBO1R1VeeobIRU= github.com/drone/go-login v1.0.3/go.mod h1:FLxy9vRzLbyBxoCJYxGbG9R0WGn6OyuvBmAtYNt43uw= github.com/drone/go-login v1.0.4-0.20190308175602-213d1719faed h1:Y0qiKFf6gsgTRTQS1roMh7kKVyrx+HSQmFsIgcZsHsM= github.com/drone/go-login v1.0.4-0.20190308175602-213d1719faed/go.mod h1:FLxy9vRzLbyBxoCJYxGbG9R0WGn6OyuvBmAtYNt43uw= +github.com/drone/go-login v1.0.4-0.20190311170324-2a4df4f242a2 h1:RGpgNkowJc5LAVn/ZONx70qmnaTA0z/3hHPzTBdAEO8= github.com/drone/go-login v1.0.4-0.20190311170324-2a4df4f242a2/go.mod h1:FLxy9vRzLbyBxoCJYxGbG9R0WGn6OyuvBmAtYNt43uw= github.com/drone/go-scm v1.2.0 h1:ezb8xCvMHX99cSOf3WPI2bmYS6tDVTTap9BiPsPmmXg= github.com/drone/go-scm v1.2.0/go.mod h1:YT4FxQ3U/ltdCrBJR9B0tRpJ1bYA/PM3NyaLE/rYIvw= diff --git a/handler/api/api.go b/handler/api/api.go index dbef3e7d4..721c901a4 100644 --- a/handler/api/api.go +++ b/handler/api/api.go @@ -34,6 +34,7 @@ import ( "github.com/drone/drone/handler/api/repos/encrypt" "github.com/drone/drone/handler/api/repos/secrets" "github.com/drone/drone/handler/api/repos/sign" + globalsecrets "github.com/drone/drone/handler/api/secrets" "github.com/drone/drone/handler/api/system" "github.com/drone/drone/handler/api/user" "github.com/drone/drone/handler/api/users" @@ -58,6 +59,7 @@ func New( commits core.CommitService, cron core.CronStore, events core.Pubsub, + globals core.GlobalSecretStore, hooks core.HookService, logs core.LogStore, license *core.License, @@ -83,6 +85,7 @@ func New( Cron: cron, Commits: commits, Events: events, + Globals: globals, Hooks: hooks, Logs: logs, License: license, @@ -111,6 +114,7 @@ type Server struct { Cron core.CronStore Commits core.CommitService Events core.Pubsub + Globals core.GlobalSecretStore Hooks core.HookService Logs core.LogStore License *core.License @@ -298,6 +302,16 @@ func (s Server) Handler() http.Handler { r.Get("/incomplete", globalbuilds.HandleIncomplete(s.Repos)) }) + r.Route("/secrets", func(r chi.Router) { + r.Use(acl.AuthorizeAdmin) + r.Get("/", globalsecrets.HandleAll(s.Globals)) + r.Get("/{namespace}", globalsecrets.HandleList(s.Globals)) + r.Post("/{namespace}", globalsecrets.HandleCreate(s.Globals)) + r.Get("/{namespace}/{name}", globalsecrets.HandleFind(s.Globals)) + r.Patch("/{namespace}/{name}", globalsecrets.HandleUpdate(s.Globals)) + r.Delete("/{namespace}/{name}", globalsecrets.HandleDelete(s.Globals)) + }) + r.Route("/system", func(r chi.Router) { r.Use(acl.AuthorizeAdmin) // r.Get("/license", system.HandleLicense()) diff --git a/handler/api/secrets/all.go b/handler/api/secrets/all.go new file mode 100644 index 000000000..f882e5c76 --- /dev/null +++ b/handler/api/secrets/all.go @@ -0,0 +1,33 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" +) + +// HandleAll returns an http.HandlerFunc that writes a json-encoded +// list of secrets to the response body. +func HandleAll(secrets core.GlobalSecretStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + list, err := secrets.ListAll(r.Context()) + if err != nil { + render.NotFound(w, err) + return + } + // the secret list is copied and the secret value is + // removed from the response. + secrets := []*core.Secret{} + for _, secret := range list { + secrets = append(secrets, secret.Copy()) + } + render.JSON(w, secrets, 200) + } +} diff --git a/handler/api/secrets/all_test.go b/handler/api/secrets/all_test.go new file mode 100644 index 000000000..1f8e7633b --- /dev/null +++ b/handler/api/secrets/all_test.go @@ -0,0 +1,65 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/errors" + "github.com/drone/drone/mock" + + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" +) + +func TestHandleAll(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().ListAll(gomock.Any()).Return(dummySecretList, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + + HandleAll(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusOK; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := []*core.Secret{}, dummySecretListScrubbed + json.NewDecoder(w.Body).Decode(&got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleAll_SecretListErr(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().ListAll(gomock.Any()).Return(nil, errors.ErrNotFound) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + + HandleAll(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusNotFound; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} diff --git a/handler/api/secrets/create.go b/handler/api/secrets/create.go new file mode 100644 index 000000000..d80ab6fc0 --- /dev/null +++ b/handler/api/secrets/create.go @@ -0,0 +1,60 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "encoding/json" + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" + "github.com/go-chi/chi" +) + +type secretInput struct { + Type string `json:"type"` + Name string `json:"name"` + Data string `json:"data"` + PullRequest bool `json:"pull_request"` + PullRequestPush bool `json:"pull_request_push"` +} + +// HandleCreate returns an http.HandlerFunc that processes http +// requests to create a new secret. +func HandleCreate(secrets core.GlobalSecretStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + in := new(secretInput) + err := json.NewDecoder(r.Body).Decode(in) + if err != nil { + render.BadRequest(w, err) + return + } + + s := &core.Secret{ + Namespace: chi.URLParam(r, "namespace"), + Name: in.Name, + Data: in.Data, + PullRequest: in.PullRequest, + PullRequestPush: in.PullRequestPush, + } + + err = s.Validate() + if err != nil { + render.BadRequest(w, err) + return + } + + err = secrets.Create(r.Context(), s) + if err != nil { + render.InternalError(w, err) + return + } + + s = s.Copy() + render.JSON(w, s, 200) + } +} diff --git a/handler/api/secrets/create_test.go b/handler/api/secrets/create_test.go new file mode 100644 index 000000000..ff6dbfec8 --- /dev/null +++ b/handler/api/secrets/create_test.go @@ -0,0 +1,139 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/errors" + "github.com/drone/drone/mock" + + "github.com/go-chi/chi" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" +) + +func TestHandleCreate(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(dummySecret) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleCreate(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusOK; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := &core.Secret{}, dummySecretScrubbed + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleCreate_ValidationError(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(&core.Secret{Name: "", Data: "pa55word"}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleCreate(nil).ServeHTTP(w, r) + if got, want := w.Code, http.StatusBadRequest; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := &errors.Error{}, &errors.Error{Message: "Invalid Secret Name"} + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleCreate_BadRequest(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleCreate(nil).ServeHTTP(w, r) + if got, want := w.Code, http.StatusBadRequest; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := &errors.Error{}, &errors.Error{Message: "EOF"} + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleCreate_CreateError(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().Create(gomock.Any(), gomock.Any()).Return(errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(dummySecret) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleCreate(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusInternalServerError; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} diff --git a/handler/api/secrets/delete.go b/handler/api/secrets/delete.go new file mode 100644 index 000000000..2948cec2b --- /dev/null +++ b/handler/api/secrets/delete.go @@ -0,0 +1,38 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" + + "github.com/go-chi/chi" +) + +// HandleDelete returns an http.HandlerFunc that processes http +// requests to delete the secret. +func HandleDelete(secrets core.GlobalSecretStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + namespace = chi.URLParam(r, "namespace") + name = chi.URLParam(r, "name") + ) + s, err := secrets.FindName(r.Context(), namespace, name) + if err != nil { + render.NotFound(w, err) + return + } + err = secrets.Delete(r.Context(), s) + if err != nil { + render.InternalError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/handler/api/secrets/delete_test.go b/handler/api/secrets/delete_test.go new file mode 100644 index 000000000..124ab87ad --- /dev/null +++ b/handler/api/secrets/delete_test.go @@ -0,0 +1,105 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/handler/api/errors" + "github.com/drone/drone/mock" + + "github.com/go-chi/chi" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" +) + +func TestHandleDelete(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(dummySecret, nil) + secrets.EXPECT().Delete(gomock.Any(), dummySecret).Return(nil) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleDelete(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusNoContent; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } +} + +func TestHandleDelete_SecretNotFound(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(nil, errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleDelete(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusNotFound; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleDelete_DeleteError(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(dummySecret, nil) + secrets.EXPECT().Delete(gomock.Any(), dummySecret).Return(errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleDelete(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusInternalServerError; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} diff --git a/handler/api/secrets/find.go b/handler/api/secrets/find.go new file mode 100644 index 000000000..feb806679 --- /dev/null +++ b/handler/api/secrets/find.go @@ -0,0 +1,34 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" + + "github.com/go-chi/chi" +) + +// HandleFind returns an http.HandlerFunc that writes json-encoded +// secret details to the the response body. +func HandleFind(secrets core.GlobalSecretStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + namespace = chi.URLParam(r, "namespace") + name = chi.URLParam(r, "name") + ) + secret, err := secrets.FindName(r.Context(), namespace, name) + if err != nil { + render.NotFound(w, err) + return + } + safe := secret.Copy() + render.JSON(w, safe, 200) + } +} diff --git a/handler/api/secrets/find_test.go b/handler/api/secrets/find_test.go new file mode 100644 index 000000000..9a2d14446 --- /dev/null +++ b/handler/api/secrets/find_test.go @@ -0,0 +1,81 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/errors" + "github.com/drone/drone/mock" + + "github.com/go-chi/chi" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" +) + +func TestHandleFind(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(dummySecret, nil) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleFind(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusOK; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := &core.Secret{}, dummySecretScrubbed + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleFind_SecretNotFound(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(nil, errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleFind(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusNotFound; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} diff --git a/handler/api/secrets/list.go b/handler/api/secrets/list.go new file mode 100644 index 000000000..9daea2d96 --- /dev/null +++ b/handler/api/secrets/list.go @@ -0,0 +1,36 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" + + "github.com/go-chi/chi" +) + +// HandleList returns an http.HandlerFunc that writes a json-encoded +// list of secrets to the response body. +func HandleList(secrets core.GlobalSecretStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + namespace := chi.URLParam(r, "namespace") + list, err := secrets.List(r.Context(), namespace) + if err != nil { + render.NotFound(w, err) + return + } + // the secret list is copied and the secret value is + // removed from the response. + secrets := []*core.Secret{} + for _, secret := range list { + secrets = append(secrets, secret.Copy()) + } + render.JSON(w, secrets, 200) + } +} diff --git a/handler/api/secrets/list_test.go b/handler/api/secrets/list_test.go new file mode 100644 index 000000000..9db84ad9b --- /dev/null +++ b/handler/api/secrets/list_test.go @@ -0,0 +1,105 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/errors" + "github.com/drone/drone/mock" + + "github.com/go-chi/chi" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" +) + +var ( + dummySecret = &core.Secret{ + Namespace: "octocat", + Name: "github_password", + Data: "pa55word", + } + + dummySecretScrubbed = &core.Secret{ + Namespace: "octocat", + Name: "github_password", + Data: "", + } + + dummySecretList = []*core.Secret{ + dummySecret, + } + + dummySecretListScrubbed = []*core.Secret{ + dummySecretScrubbed, + } +) + +// +// HandleList +// + +func TestHandleList(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().List(gomock.Any(), dummySecret.Namespace).Return(dummySecretList, nil) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleList(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusOK; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := []*core.Secret{}, dummySecretListScrubbed + json.NewDecoder(w.Body).Decode(&got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleList_SecretListErr(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().List(gomock.Any(), dummySecret.Namespace).Return(nil, errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleList(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusNotFound; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} diff --git a/handler/api/secrets/none.go b/handler/api/secrets/none.go new file mode 100644 index 000000000..a602c41ea --- /dev/null +++ b/handler/api/secrets/none.go @@ -0,0 +1,52 @@ +// Copyright 2019 Drone IO, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build oss + +package secrets + +import ( + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" +) + +var notImplemented = func(w http.ResponseWriter, r *http.Request) { + render.NotImplemented(w, render.ErrNotImplemented) +} + +func HandleCreate(core.GlobalSecretStore) http.HandlerFunc { + return notImplemented +} + +func HandleUpdate(core.GlobalSecretStore) http.HandlerFunc { + return notImplemented +} + +func HandleDelete(core.GlobalSecretStore) http.HandlerFunc { + return notImplemented +} + +func HandleFind(core.GlobalSecretStore) http.HandlerFunc { + return notImplemented +} + +func HandleList(core.GlobalSecretStore) http.HandlerFunc { + return notImplemented +} + +func HandleAll(core.GlobalSecretStore) http.HandlerFunc { + return notImplemented +} diff --git a/handler/api/secrets/update.go b/handler/api/secrets/update.go new file mode 100644 index 000000000..7251eb254 --- /dev/null +++ b/handler/api/secrets/update.go @@ -0,0 +1,72 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "encoding/json" + "net/http" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/render" + + "github.com/go-chi/chi" +) + +type secretUpdate struct { + Data *string `json:"data"` + PullRequest *bool `json:"pull_request"` + PullRequestPush *bool `json:"pull_request_push"` +} + +// HandleUpdate returns an http.HandlerFunc that processes http +// requests to update a secret. +func HandleUpdate(secrets core.GlobalSecretStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + namespace = chi.URLParam(r, "namespace") + name = chi.URLParam(r, "name") + ) + + in := new(secretUpdate) + err := json.NewDecoder(r.Body).Decode(in) + if err != nil { + render.BadRequest(w, err) + return + } + + s, err := secrets.FindName(r.Context(), namespace, name) + if err != nil { + render.NotFound(w, err) + return + } + + if in.Data != nil { + s.Data = *in.Data + } + if in.PullRequest != nil { + s.PullRequest = *in.PullRequest + } + if in.PullRequestPush != nil { + s.PullRequestPush = *in.PullRequestPush + } + + err = s.Validate() + if err != nil { + render.BadRequest(w, err) + return + } + + err = secrets.Update(r.Context(), s) + if err != nil { + render.InternalError(w, err) + return + } + + s = s.Copy() + render.JSON(w, s, 200) + } +} diff --git a/handler/api/secrets/update_test.go b/handler/api/secrets/update_test.go new file mode 100644 index 000000000..4cce15fe2 --- /dev/null +++ b/handler/api/secrets/update_test.go @@ -0,0 +1,180 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package secrets + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/drone/drone/core" + "github.com/drone/drone/handler/api/errors" + "github.com/drone/drone/mock" + + "github.com/go-chi/chi" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" +) + +func TestHandleUpdate(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(dummySecret, nil) + secrets.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(dummySecret) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleUpdate(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusOK; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(core.Secret), dummySecretScrubbed + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleUpdate_ValidationError(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(&core.Secret{Name: "github_password"}, nil) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(&core.Secret{Data: ""}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleUpdate(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusBadRequest; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), &errors.Error{Message: "Invalid Secret Value"} + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleUpdate_BadRequest(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleUpdate(nil).ServeHTTP(w, r) + if got, want := w.Code, http.StatusBadRequest; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), &errors.Error{Message: "EOF"} + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleUpdate_SecretNotFound(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(nil, errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(&core.Secret{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleUpdate(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusNotFound; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} + +func TestHandleUpdate_UpdateError(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + + secrets := mock.NewMockGlobalSecretStore(controller) + secrets.EXPECT().FindName(gomock.Any(), dummySecret.Namespace, dummySecret.Name).Return(&core.Secret{Name: "github_password"}, nil) + secrets.EXPECT().Update(gomock.Any(), gomock.Any()).Return(errors.ErrNotFound) + + c := new(chi.Context) + c.URLParams.Add("namespace", "octocat") + c.URLParams.Add("name", "github_password") + + in := new(bytes.Buffer) + json.NewEncoder(in).Encode(&core.Secret{Data: "password"}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", in) + r = r.WithContext( + context.WithValue(context.Background(), chi.RouteCtxKey, c), + ) + + HandleUpdate(secrets).ServeHTTP(w, r) + if got, want := w.Code, http.StatusInternalServerError; want != got { + t.Errorf("Want response code %d, got %d", want, got) + } + + got, want := new(errors.Error), errors.ErrNotFound + json.NewDecoder(w.Body).Decode(got) + if diff := cmp.Diff(got, want); len(diff) != 0 { + t.Errorf(diff) + } +} diff --git a/mock/mock.go b/mock/mock.go index 4ac556e78..6c6e266c0 100644 --- a/mock/mock.go +++ b/mock/mock.go @@ -6,4 +6,4 @@ package mock -//go:generate mockgen -package=mock -destination=mock_gen.go github.com/drone/drone/core NetrcService,Renewer,HookParser,UserService,RepositoryService,CommitService,StatusService,HookService,FileService,Batcher,BuildStore,CronStore,LogStore,PermStore,SecretStore,StageStore,StepStore,RepositoryStore,UserStore,Scheduler,Session,OrganizationService,SecretService,RegistryService,ConfigService,Triggerer,Syncer,LogStream,WebhookSender,LicenseService +//go:generate mockgen -package=mock -destination=mock_gen.go github.com/drone/drone/core NetrcService,Renewer,HookParser,UserService,RepositoryService,CommitService,StatusService,HookService,FileService,Batcher,BuildStore,CronStore,LogStore,PermStore,SecretStore,GlobalSecretStore,StageStore,StepStore,RepositoryStore,UserStore,Scheduler,Session,OrganizationService,SecretService,RegistryService,ConfigService,Triggerer,Syncer,LogStream,WebhookSender,LicenseService diff --git a/mock/mock_gen.go b/mock/mock_gen.go index 79408dfa8..38aeb22c6 100644 --- a/mock/mock_gen.go +++ b/mock/mock_gen.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/drone/drone/core (interfaces: NetrcService,Renewer,HookParser,UserService,RepositoryService,CommitService,StatusService,HookService,FileService,Batcher,BuildStore,CronStore,LogStore,PermStore,SecretStore,StageStore,StepStore,RepositoryStore,UserStore,Scheduler,Session,OrganizationService,SecretService,RegistryService,ConfigService,Triggerer,Syncer,LogStream,WebhookSender,LicenseService) +// Source: github.com/drone/drone/core (interfaces: NetrcService,Renewer,HookParser,UserService,RepositoryService,CommitService,StatusService,HookService,FileService,Batcher,BuildStore,CronStore,LogStore,PermStore,SecretStore,GlobalSecretStore,StageStore,StepStore,RepositoryStore,UserStore,Scheduler,Session,OrganizationService,SecretService,RegistryService,ConfigService,Triggerer,Syncer,LogStream,WebhookSender,LicenseService) // Package mock is a generated GoMock package. package mock @@ -963,6 +963,117 @@ func (mr *MockSecretStoreMockRecorder) Update(arg0, arg1 interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSecretStore)(nil).Update), arg0, arg1) } +// MockGlobalSecretStore is a mock of GlobalSecretStore interface +type MockGlobalSecretStore struct { + ctrl *gomock.Controller + recorder *MockGlobalSecretStoreMockRecorder +} + +// MockGlobalSecretStoreMockRecorder is the mock recorder for MockGlobalSecretStore +type MockGlobalSecretStoreMockRecorder struct { + mock *MockGlobalSecretStore +} + +// NewMockGlobalSecretStore creates a new mock instance +func NewMockGlobalSecretStore(ctrl *gomock.Controller) *MockGlobalSecretStore { + mock := &MockGlobalSecretStore{ctrl: ctrl} + mock.recorder = &MockGlobalSecretStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockGlobalSecretStore) EXPECT() *MockGlobalSecretStoreMockRecorder { + return m.recorder +} + +// Create mocks base method +func (m *MockGlobalSecretStore) Create(arg0 context.Context, arg1 *core.Secret) error { + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create +func (mr *MockGlobalSecretStoreMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockGlobalSecretStore)(nil).Create), arg0, arg1) +} + +// Delete mocks base method +func (m *MockGlobalSecretStore) Delete(arg0 context.Context, arg1 *core.Secret) error { + ret := m.ctrl.Call(m, "Delete", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete +func (mr *MockGlobalSecretStoreMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockGlobalSecretStore)(nil).Delete), arg0, arg1) +} + +// Find mocks base method +func (m *MockGlobalSecretStore) Find(arg0 context.Context, arg1 int64) (*core.Secret, error) { + ret := m.ctrl.Call(m, "Find", arg0, arg1) + ret0, _ := ret[0].(*core.Secret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Find indicates an expected call of Find +func (mr *MockGlobalSecretStoreMockRecorder) Find(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockGlobalSecretStore)(nil).Find), arg0, arg1) +} + +// FindName mocks base method +func (m *MockGlobalSecretStore) FindName(arg0 context.Context, arg1, arg2 string) (*core.Secret, error) { + ret := m.ctrl.Call(m, "FindName", arg0, arg1, arg2) + ret0, _ := ret[0].(*core.Secret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindName indicates an expected call of FindName +func (mr *MockGlobalSecretStoreMockRecorder) FindName(arg0, arg1, arg2 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindName", reflect.TypeOf((*MockGlobalSecretStore)(nil).FindName), arg0, arg1, arg2) +} + +// List mocks base method +func (m *MockGlobalSecretStore) List(arg0 context.Context, arg1 string) ([]*core.Secret, error) { + ret := m.ctrl.Call(m, "List", arg0, arg1) + ret0, _ := ret[0].([]*core.Secret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List +func (mr *MockGlobalSecretStoreMockRecorder) List(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockGlobalSecretStore)(nil).List), arg0, arg1) +} + +// ListAll mocks base method +func (m *MockGlobalSecretStore) ListAll(arg0 context.Context) ([]*core.Secret, error) { + ret := m.ctrl.Call(m, "ListAll", arg0) + ret0, _ := ret[0].([]*core.Secret) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAll indicates an expected call of ListAll +func (mr *MockGlobalSecretStoreMockRecorder) ListAll(arg0 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAll", reflect.TypeOf((*MockGlobalSecretStore)(nil).ListAll), arg0) +} + +// Update mocks base method +func (m *MockGlobalSecretStore) Update(arg0 context.Context, arg1 *core.Secret) error { + ret := m.ctrl.Call(m, "Update", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update +func (mr *MockGlobalSecretStoreMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockGlobalSecretStore)(nil).Update), arg0, arg1) +} + // MockStageStore is a mock of StageStore interface type MockStageStore struct { ctrl *gomock.Controller diff --git a/operator/manager/manager.go b/operator/manager/manager.go index ba51f3f3a..2d5eee708 100644 --- a/operator/manager/manager.go +++ b/operator/manager/manager.go @@ -108,6 +108,7 @@ func New( repos core.RepositoryStore, scheduler core.Scheduler, secrets core.SecretStore, + globals core.GlobalSecretStore, status core.StatusService, stages core.StageStore, steps core.StepStore, @@ -119,6 +120,7 @@ func New( Builds: builds, Config: config, Events: events, + Globals: globals, Logs: logs, Logz: logz, Netrcs: netrcs, @@ -140,6 +142,7 @@ type Manager struct { Builds core.BuildStore Config core.ConfigService Events core.Pubsub + Globals core.GlobalSecretStore Logs core.LogStore Logz core.LogStream Netrcs core.NetrcService @@ -287,6 +290,12 @@ func (m *Manager) Details(ctx context.Context, id int64) (*Context, error) { logger.Warnln("manager: cannot list secrets") return nil, err } + tmpGlobalSecrets, err := m.Globals.List(noContext, repo.Namespace) + if err != nil { + logger = logger.WithError(err) + logger.Warnln("manager: cannot list global secrets") + return nil, err + } // TODO(bradrydzewski) can we delegate filtering // secrets to the agent? If not, we should add // unit tests. @@ -297,6 +306,13 @@ func (m *Manager) Details(ctx context.Context, id int64) (*Context, error) { } secrets = append(secrets, secret) } + for _, secret := range tmpGlobalSecrets { + if secret.PullRequest == false && + build.Event == core.EventPullRequest { + continue + } + secrets = append(secrets, secret) + } return &Context{ Repo: repo, Build: build, diff --git a/service/license/nolimit.go b/service/license/nolimit.go index 80d0a6ba6..4d01b7e95 100644 --- a/service/license/nolimit.go +++ b/service/license/nolimit.go @@ -17,11 +17,11 @@ package license import ( - "github.com/drone/drone/core" + "github.com/drone/drone/core" ) // DefaultLicense is an empty license with no restrictions. var DefaultLicense = &core.License{Kind: core.LicenseFoss} -func Trial(string) *core.License { return nil } +func Trial(string) *core.License { return DefaultLicense } func Load(string) (*core.License, error) { return DefaultLicense, nil } diff --git a/store/secret/global/scan.go b/store/secret/global/scan.go new file mode 100644 index 000000000..8b975aac9 --- /dev/null +++ b/store/secret/global/scan.go @@ -0,0 +1,74 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package global + +import ( + "database/sql" + + "github.com/drone/drone/core" + "github.com/drone/drone/store/shared/db" + "github.com/drone/drone/store/shared/encrypt" +) + +// helper function converts the User structure to a set +// of named query parameters. +func toParams(encrypt encrypt.Encrypter, secret *core.Secret) (map[string]interface{}, error) { + ciphertext, err := encrypt.Encrypt(secret.Data) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "secret_id": secret.ID, + "secret_namespace": secret.Namespace, + "secret_name": secret.Name, + "secret_type": secret.Type, + "secret_data": ciphertext, + "secret_pull_request": secret.PullRequest, + "secret_pull_request_push": secret.PullRequestPush, + }, nil +} + +// helper function scans the sql.Row and copies the column +// values to the destination object. +func scanRow(encrypt encrypt.Encrypter, scanner db.Scanner, dst *core.Secret) error { + var ciphertext []byte + err := scanner.Scan( + &dst.ID, + &dst.Namespace, + &dst.Name, + &dst.Type, + &ciphertext, + &dst.PullRequest, + &dst.PullRequestPush, + ) + if err != nil { + return err + } + plaintext, err := encrypt.Decrypt(ciphertext) + if err != nil { + return err + } + dst.Data = plaintext + return nil +} + +// helper function scans the sql.Row and copies the column +// values to the destination object. +func scanRows(encrypt encrypt.Encrypter, rows *sql.Rows) ([]*core.Secret, error) { + defer rows.Close() + + secrets := []*core.Secret{} + for rows.Next() { + sec := new(core.Secret) + err := scanRow(encrypt, rows, sec) + if err != nil { + return nil, err + } + secrets = append(secrets, sec) + } + return secrets, nil +} diff --git a/store/secret/global/secret.go b/store/secret/global/secret.go new file mode 100644 index 000000000..1f37e56f9 --- /dev/null +++ b/store/secret/global/secret.go @@ -0,0 +1,233 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package global + +import ( + "context" + + "github.com/drone/drone/core" + "github.com/drone/drone/store/shared/db" + "github.com/drone/drone/store/shared/encrypt" +) + +// New returns a new global Secret database store. +func New(db *db.DB, enc encrypt.Encrypter) core.GlobalSecretStore { + return &secretStore{ + db: db, + enc: enc, + } +} + +type secretStore struct { + db *db.DB + enc encrypt.Encrypter +} + +func (s *secretStore) List(ctx context.Context, namespace string) ([]*core.Secret, error) { + var out []*core.Secret + err := s.db.View(func(queryer db.Queryer, binder db.Binder) error { + params := map[string]interface{}{"secret_namespace": namespace} + stmt, args, err := binder.BindNamed(queryNamespace, params) + if err != nil { + return err + } + rows, err := queryer.Query(stmt, args...) + if err != nil { + return err + } + out, err = scanRows(s.enc, rows) + return err + }) + return out, err +} + +func (s *secretStore) ListAll(ctx context.Context) ([]*core.Secret, error) { + var out []*core.Secret + err := s.db.View(func(queryer db.Queryer, binder db.Binder) error { + rows, err := queryer.Query(queryAll) + if err != nil { + return err + } + out, err = scanRows(s.enc, rows) + return err + }) + return out, err +} + +func (s *secretStore) Find(ctx context.Context, id int64) (*core.Secret, error) { + out := &core.Secret{ID: id} + err := s.db.View(func(queryer db.Queryer, binder db.Binder) error { + params, err := toParams(s.enc, out) + if err != nil { + return err + } + query, args, err := binder.BindNamed(queryKey, params) + if err != nil { + return err + } + row := queryer.QueryRow(query, args...) + return scanRow(s.enc, row, out) + }) + return out, err +} + +func (s *secretStore) FindName(ctx context.Context, namespace, name string) (*core.Secret, error) { + out := &core.Secret{Name: name, Namespace: namespace} + err := s.db.View(func(queryer db.Queryer, binder db.Binder) error { + params, err := toParams(s.enc, out) + if err != nil { + return err + } + query, args, err := binder.BindNamed(queryName, params) + if err != nil { + return err + } + row := queryer.QueryRow(query, args...) + return scanRow(s.enc, row, out) + }) + return out, err +} + +func (s *secretStore) Create(ctx context.Context, secret *core.Secret) error { + if s.db.Driver() == db.Postgres { + return s.createPostgres(ctx, secret) + } + return s.create(ctx, secret) +} + +func (s *secretStore) create(ctx context.Context, secret *core.Secret) error { + return s.db.Lock(func(execer db.Execer, binder db.Binder) error { + params, err := toParams(s.enc, secret) + if err != nil { + return err + } + stmt, args, err := binder.BindNamed(stmtInsert, params) + if err != nil { + return err + } + res, err := execer.Exec(stmt, args...) + if err != nil { + return err + } + secret.ID, err = res.LastInsertId() + return err + }) +} + +func (s *secretStore) createPostgres(ctx context.Context, secret *core.Secret) error { + return s.db.Lock(func(execer db.Execer, binder db.Binder) error { + params, err := toParams(s.enc, secret) + if err != nil { + return err + } + stmt, args, err := binder.BindNamed(stmtInsertPg, params) + if err != nil { + return err + } + return execer.QueryRow(stmt, args...).Scan(&secret.ID) + }) +} + +func (s *secretStore) Update(ctx context.Context, secret *core.Secret) error { + return s.db.Lock(func(execer db.Execer, binder db.Binder) error { + params, err := toParams(s.enc, secret) + if err != nil { + return err + } + stmt, args, err := binder.BindNamed(stmtUpdate, params) + if err != nil { + return err + } + _, err = execer.Exec(stmt, args...) + return err + }) +} + +func (s *secretStore) Delete(ctx context.Context, secret *core.Secret) error { + return s.db.Lock(func(execer db.Execer, binder db.Binder) error { + params, err := toParams(s.enc, secret) + if err != nil { + return err + } + stmt, args, err := binder.BindNamed(stmtDelete, params) + if err != nil { + return err + } + _, err = execer.Exec(stmt, args...) + return err + }) +} + +const queryBase = ` +SELECT + secret_id +,secret_namespace +,secret_name +,secret_type +,secret_data +,secret_pull_request +,secret_pull_request_push +` + +const queryKey = queryBase + ` +FROM orgsecrets +WHERE secret_id = :secret_id +LIMIT 1 +` + +const queryAll = queryBase + ` +FROM orgsecrets +ORDER BY secret_name +` + +const queryName = queryBase + ` +FROM orgsecrets +WHERE secret_name = :secret_name + AND secret_namespace = :secret_namespace +LIMIT 1 +` + +const queryNamespace = queryBase + ` +FROM orgsecrets +WHERE secret_namespace = :secret_namespace +ORDER BY secret_name +` + +const stmtUpdate = ` +UPDATE orgsecrets SET + secret_data = :secret_data +,secret_pull_request = :secret_pull_request +,secret_pull_request_push = :secret_pull_request_push +WHERE secret_id = :secret_id +` + +const stmtDelete = ` +DELETE FROM orgsecrets +WHERE secret_id = :secret_id +` + +const stmtInsert = ` +INSERT INTO orgsecrets ( + secret_namespace +,secret_name +,secret_type +,secret_data +,secret_pull_request +,secret_pull_request_push +) VALUES ( + :secret_namespace +,:secret_name +,:secret_type +,:secret_data +,:secret_pull_request +,:secret_pull_request_push +) +` + +const stmtInsertPg = stmtInsert + ` +RETURNING secret_id +` diff --git a/store/secret/global/secret_oss.go b/store/secret/global/secret_oss.go new file mode 100644 index 000000000..1ed727032 --- /dev/null +++ b/store/secret/global/secret_oss.go @@ -0,0 +1,60 @@ +// Copyright 2019 Drone IO, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build oss + +package global + +import ( + "context" + + "github.com/drone/drone/core" + "github.com/drone/drone/store/shared/db" + "github.com/drone/drone/store/shared/encrypt" +) + +// New returns a new Secret database store. +func New(db *db.DB, enc encrypt.Encrypter) core.GlobalSecretStore { + return new(noop) +} + +type noop struct{} + +func (noop) List(context.Context, string) ([]*core.Secret, error) { + return nil, nil +} + +func (noop) ListAll(context.Context) ([]*core.Secret, error) { + return nil, nil +} + +func (noop) Find(context.Context, int64) (*core.Secret, error) { + return nil, nil +} + +func (noop) FindName(context.Context, string, string) (*core.Secret, error) { + return nil, nil +} + +func (noop) Create(context.Context, *core.Secret) error { + return nil +} + +func (noop) Update(context.Context, *core.Secret) error { + return nil +} + +func (noop) Delete(context.Context, *core.Secret) error { + return nil +} diff --git a/store/secret/global/secret_test.go b/store/secret/global/secret_test.go new file mode 100644 index 000000000..fbbb8f45f --- /dev/null +++ b/store/secret/global/secret_test.go @@ -0,0 +1,165 @@ +// Copyright 2019 Drone.IO Inc. All rights reserved. +// Use of this source code is governed by the Drone Non-Commercial License +// that can be found in the LICENSE file. + +// +build !oss + +package global + +import ( + "context" + "database/sql" + "testing" + + "github.com/drone/drone/core" + "github.com/drone/drone/store/shared/db/dbtest" + "github.com/drone/drone/store/shared/encrypt" +) + +var noContext = context.TODO() + +func TestSecret(t *testing.T) { + conn, err := dbtest.Connect() + if err != nil { + t.Error(err) + return + } + defer func() { + dbtest.Reset(conn) + dbtest.Disconnect(conn) + }() + + store := New(conn, nil).(*secretStore) + store.enc, _ = encrypt.New("fb4b4d6267c8a5ce8231f8b186dbca92") + t.Run("Create", testSecretCreate(store)) +} + +func testSecretCreate(store *secretStore) func(t *testing.T) { + return func(t *testing.T) { + item := &core.Secret{ + Namespace: "octocat", + Name: "password", + Data: "correct-horse-battery-staple", + } + err := store.Create(noContext, item) + if err != nil { + t.Error(err) + } + if item.ID == 0 { + t.Errorf("Want secret ID assigned, got %d", item.ID) + } + + t.Run("Find", testSecretFind(store, item)) + t.Run("FindName", testSecretFindName(store)) + t.Run("List", testSecretList(store)) + t.Run("ListAll", testSecretListAll(store)) + t.Run("Update", testSecretUpdate(store)) + t.Run("Delete", testSecretDelete(store)) + } +} + +func testSecretFind(store *secretStore, secret *core.Secret) func(t *testing.T) { + return func(t *testing.T) { + item, err := store.Find(noContext, secret.ID) + if err != nil { + t.Error(err) + } else { + t.Run("Fields", testSecret(item)) + } + } +} + +func testSecretFindName(store *secretStore) func(t *testing.T) { + return func(t *testing.T) { + item, err := store.FindName(noContext, "octocat", "password") + if err != nil { + t.Error(err) + } else { + t.Run("Fields", testSecret(item)) + } + } +} + +func testSecretList(store *secretStore) func(t *testing.T) { + return func(t *testing.T) { + list, err := store.List(noContext, "octocat") + if err != nil { + t.Error(err) + return + } + if got, want := len(list), 1; got != want { + t.Errorf("Want count %d, got %d", want, got) + } else { + t.Run("Fields", testSecret(list[0])) + } + } +} + +func testSecretListAll(store *secretStore) func(t *testing.T) { + return func(t *testing.T) { + list, err := store.ListAll(noContext) + if err != nil { + t.Error(err) + return + } + if got, want := len(list), 1; got != want { + t.Errorf("Want count %d, got %d", want, got) + } else { + t.Run("Fields", testSecret(list[0])) + } + } +} + +func testSecretUpdate(store *secretStore) func(t *testing.T) { + return func(t *testing.T) { + before, err := store.FindName(noContext, "octocat", "password") + if err != nil { + t.Error(err) + return + } + err = store.Update(noContext, before) + if err != nil { + t.Error(err) + return + } + after, err := store.Find(noContext, before.ID) + if err != nil { + t.Error(err) + return + } + if after == nil { + t.Fail() + } + } +} + +func testSecretDelete(store *secretStore) func(t *testing.T) { + return func(t *testing.T) { + secret, err := store.FindName(noContext, "octocat", "password") + if err != nil { + t.Error(err) + return + } + err = store.Delete(noContext, secret) + if err != nil { + t.Error(err) + return + } + _, err = store.Find(noContext, secret.ID) + if got, want := sql.ErrNoRows, err; got != want { + t.Errorf("Want sql.ErrNoRows, got %v", got) + return + } + } +} + +func testSecret(item *core.Secret) func(t *testing.T) { + return func(t *testing.T) { + if got, want := item.Name, "password"; got != want { + t.Errorf("Want secret name %q, got %q", want, got) + } + if got, want := item.Data, "correct-horse-battery-staple"; got != want { + t.Errorf("Want secret data %q, got %q", want, got) + } + } +} diff --git a/store/shared/migrate/mysql/ddl_gen.go b/store/shared/migrate/mysql/ddl_gen.go index acc0722c7..9511db2b3 100644 --- a/store/shared/migrate/mysql/ddl_gen.go +++ b/store/shared/migrate/mysql/ddl_gen.go @@ -120,6 +120,10 @@ var migrations = []struct { name: "alter-table-builds-add-column-cron", stmt: alterTableBuildsAddColumnCron, }, + { + name: "create-table-org-secrets", + stmt: createTableOrgSecrets, + }, } // Migrate performs the database migration. If the migration fails @@ -556,3 +560,20 @@ CREATE TABLE IF NOT EXISTS nodes ( var alterTableBuildsAddColumnCron = ` ALTER TABLE builds ADD COLUMN build_cron VARCHAR(50) NOT NULL DEFAULT ''; ` + +// +// 012_create_table_global_secrets.sql +// + +var createTableOrgSecrets = ` +CREATE TABLE IF NOT EXISTS orgsecrets ( + secret_id INTEGER PRIMARY KEY AUTOINCREMENT +,secret_namespace VARCHAR(50) +,secret_name VARCHAR(200) +,secret_type VARCHAR(50) +,secret_data BLOB +,secret_pull_request BOOLEAN +,secret_pull_request_push BOOLEAN +,UNIQUE(secret_namespace, secret_name) +); +` diff --git a/store/shared/migrate/mysql/files/012_create_table_global_secrets.sql b/store/shared/migrate/mysql/files/012_create_table_global_secrets.sql new file mode 100644 index 000000000..62808956c --- /dev/null +++ b/store/shared/migrate/mysql/files/012_create_table_global_secrets.sql @@ -0,0 +1,12 @@ +-- name: create-table-org-secrets + +CREATE TABLE IF NOT EXISTS orgsecrets ( + secret_id INTEGER PRIMARY KEY AUTOINCREMENT +,secret_namespace VARCHAR(50) +,secret_name VARCHAR(200) +,secret_type VARCHAR(50) +,secret_data BLOB +,secret_pull_request BOOLEAN +,secret_pull_request_push BOOLEAN +,UNIQUE(secret_namespace, secret_name) +); diff --git a/store/shared/migrate/postgres/ddl_gen.go b/store/shared/migrate/postgres/ddl_gen.go index 8f67595d4..08ac04501 100644 --- a/store/shared/migrate/postgres/ddl_gen.go +++ b/store/shared/migrate/postgres/ddl_gen.go @@ -116,6 +116,10 @@ var migrations = []struct { name: "alter-table-builds-add-column-cron", stmt: alterTableBuildsAddColumnCron, }, + { + name: "create-table-org-secrets", + stmt: createTableOrgSecrets, + }, } // Migrate performs the database migration. If the migration fails @@ -534,3 +538,20 @@ CREATE TABLE IF NOT EXISTS nodes ( var alterTableBuildsAddColumnCron = ` ALTER TABLE builds ADD COLUMN build_cron VARCHAR(50) NOT NULL DEFAULT ''; ` + +// +// 012_create_table_org_secrets.sql +// + +var createTableOrgSecrets = ` +CREATE TABLE IF NOT EXISTS orgsecrets ( + secret_id INTEGER PRIMARY KEY AUTOINCREMENT +,secret_namespace VARCHAR(50) +,secret_name VARCHAR(200) +,secret_type VARCHAR(50) +,secret_data BYTEA +,secret_pull_request BOOLEAN +,secret_pull_request_push BOOLEAN +,UNIQUE(secret_namespace, secret_name) +); +` diff --git a/store/shared/migrate/postgres/files/012_create_table_org_secrets.sql b/store/shared/migrate/postgres/files/012_create_table_org_secrets.sql new file mode 100644 index 000000000..269f7aed3 --- /dev/null +++ b/store/shared/migrate/postgres/files/012_create_table_org_secrets.sql @@ -0,0 +1,12 @@ +-- name: create-table-org-secrets + +CREATE TABLE IF NOT EXISTS orgsecrets ( + secret_id INTEGER PRIMARY KEY AUTOINCREMENT +,secret_namespace VARCHAR(50) +,secret_name VARCHAR(200) +,secret_type VARCHAR(50) +,secret_data BYTEA +,secret_pull_request BOOLEAN +,secret_pull_request_push BOOLEAN +,UNIQUE(secret_namespace, secret_name) +); diff --git a/store/shared/migrate/sqlite/ddl_gen.go b/store/shared/migrate/sqlite/ddl_gen.go index 12cbaf0cb..6c0bb8b75 100644 --- a/store/shared/migrate/sqlite/ddl_gen.go +++ b/store/shared/migrate/sqlite/ddl_gen.go @@ -116,6 +116,10 @@ var migrations = []struct { name: "alter-table-builds-add-column-cron", stmt: alterTableBuildsAddColumnCron, }, + { + name: "create-table-org-secrets", + stmt: createTableOrgSecrets, + }, } // Migrate performs the database migration. If the migration fails @@ -536,3 +540,20 @@ CREATE TABLE IF NOT EXISTS nodes ( var alterTableBuildsAddColumnCron = ` ALTER TABLE builds ADD COLUMN build_cron TEXT NOT NULL DEFAULT ''; ` + +// +// 012_create_table_org_secrets.sql +// + +var createTableOrgSecrets = ` +CREATE TABLE IF NOT EXISTS orgsecrets ( + secret_id INTEGER PRIMARY KEY AUTOINCREMENT +,secret_namespace TEXT COLLATE NOCASE +,secret_name TEXT COLLATE NOCASE +,secret_type TEXT +,secret_data BLOB +,secret_pull_request BOOLEAN +,secret_pull_request_push BOOLEAN +,UNIQUE(secret_namespace, secret_name) +); +` diff --git a/store/shared/migrate/sqlite/files/012_create_table_org_secrets.sql b/store/shared/migrate/sqlite/files/012_create_table_org_secrets.sql new file mode 100644 index 000000000..42808515c --- /dev/null +++ b/store/shared/migrate/sqlite/files/012_create_table_org_secrets.sql @@ -0,0 +1,12 @@ +-- name: create-table-org-secrets + +CREATE TABLE IF NOT EXISTS orgsecrets ( + secret_id INTEGER PRIMARY KEY AUTOINCREMENT +,secret_namespace TEXT COLLATE NOCASE +,secret_name TEXT COLLATE NOCASE +,secret_type TEXT +,secret_data BLOB +,secret_pull_request BOOLEAN +,secret_pull_request_push BOOLEAN +,UNIQUE(secret_namespace, secret_name) +);