diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d2a338e40..fba35f380 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,7 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "voorjaar.windicss-intellisense", - "johnsoncodehk.volar", + "Vue.volar", "redhat.vscode-yaml", "davidanson.vscode-markdownlint" ], diff --git a/go.mod b/go.mod index b5dd2b358..474b9e909 100644 --- a/go.mod +++ b/go.mod @@ -42,8 +42,8 @@ require ( google.golang.org/grpc v1.47.0 google.golang.org/protobuf v1.28.0 gopkg.in/yaml.v3 v3.0.1 - xorm.io/builder v0.3.10 - xorm.io/xorm v1.3.0 + xorm.io/builder v0.3.12 + xorm.io/xorm v1.3.1 ) require ( diff --git a/go.sum b/go.sum index a2c555568..05b9ef998 100644 --- a/go.sum +++ b/go.sum @@ -1199,8 +1199,8 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= -xorm.io/builder v0.3.9/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= -xorm.io/builder v0.3.10 h1:Rvkncad3Lo9YIVqCbgIf6QnpR/HcW3IEr0AANNpuyMQ= -xorm.io/builder v0.3.10/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= -xorm.io/xorm v1.3.0 h1:UsVke0wyAk3tJcb0j15gLWv2DEshVUnySVyvcYDny8w= -xorm.io/xorm v1.3.0/go.mod h1:cEaWjDPqoIusTkmDAG+krCcPcTglqo8CDU8geX/yhko= +xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/builder v0.3.12 h1:ASZYX7fQmy+o8UJdhlLHSW57JDOkM8DNhcAF5d0LiJM= +xorm.io/builder v0.3.12/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/xorm v1.3.1 h1:z5egKrDoOLqZFhMjcGF4FBHiTmE5/feQoHclfhNidfM= +xorm.io/xorm v1.3.1/go.mod h1:9NbjqdnjX6eyjRRhh01GHm64r6N9shTb/8Ak3YRt8Nw= diff --git a/server/api/global_secret.go b/server/api/global_secret.go new file mode 100644 index 000000000..31a3ea0c3 --- /dev/null +++ b/server/api/global_secret.go @@ -0,0 +1,123 @@ +// Copyright 2022 Woodpecker Authors +// +// 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. + +package api + +import ( + "net/http" + + "github.com/woodpecker-ci/woodpecker/server" + "github.com/woodpecker-ci/woodpecker/server/model" + + "github.com/gin-gonic/gin" +) + +// GetGlobalSecretList gets the global secret list from +// the database and writes to the response in json format. +func GetGlobalSecretList(c *gin.Context) { + list, err := server.Config.Services.Secrets.GlobalSecretList() + if err != nil { + c.String(http.StatusInternalServerError, "Error getting global secret list. %s", err) + return + } + // copy the secret detail to remove the sensitive + // password and token fields. + for i, secret := range list { + list[i] = secret.Copy() + } + c.JSON(http.StatusOK, list) +} + +// GetGlobalSecret gets the named global secret from the database +// and writes to the response in json format. +func GetGlobalSecret(c *gin.Context) { + name := c.Param("secret") + secret, err := server.Config.Services.Secrets.GlobalSecretFind(name) + if err != nil { + c.String(404, "Error getting global secret %q. %s", name, err) + return + } + c.JSON(200, secret.Copy()) +} + +// PostGlobalSecret persists a global secret to the database. +func PostGlobalSecret(c *gin.Context) { + in := new(model.Secret) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, "Error parsing global secret. %s", err) + return + } + secret := &model.Secret{ + Name: in.Name, + Value: in.Value, + Events: in.Events, + Images: in.Images, + } + if err := secret.Validate(); err != nil { + c.String(400, "Error inserting global secret. %s", err) + return + } + if err := server.Config.Services.Secrets.GlobalSecretCreate(secret); err != nil { + c.String(500, "Error inserting global secret %q. %s", in.Name, err) + return + } + c.JSON(200, secret.Copy()) +} + +// PatchGlobalSecret updates a global secret in the database. +func PatchGlobalSecret(c *gin.Context) { + name := c.Param("secret") + + in := new(model.Secret) + err := c.Bind(in) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing secret. %s", err) + return + } + + secret, err := server.Config.Services.Secrets.GlobalSecretFind(name) + if err != nil { + c.String(404, "Error getting global secret %q. %s", name, err) + return + } + if in.Value != "" { + secret.Value = in.Value + } + if in.Events != nil { + secret.Events = in.Events + } + if in.Images != nil { + secret.Images = in.Images + } + + if err := secret.Validate(); err != nil { + c.String(400, "Error updating global secret. %s", err) + return + } + if err := server.Config.Services.Secrets.GlobalSecretUpdate(secret); err != nil { + c.String(500, "Error updating global secret %q. %s", in.Name, err) + return + } + c.JSON(200, secret.Copy()) +} + +// DeleteGlobalSecret deletes the named global secret from the database. +func DeleteGlobalSecret(c *gin.Context) { + name := c.Param("secret") + if err := server.Config.Services.Secrets.GlobalSecretDelete(name); err != nil { + c.String(500, "Error deleting global secret %q. %s", name, err) + return + } + c.String(204, "") +} diff --git a/server/api/org.go b/server/api/org.go new file mode 100644 index 000000000..e79a3ae31 --- /dev/null +++ b/server/api/org.go @@ -0,0 +1,47 @@ +// Copyright 2022 Woodpecker Authors +// +// 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. + +package api + +import ( + "net/http" + + "github.com/woodpecker-ci/woodpecker/server" + "github.com/woodpecker-ci/woodpecker/server/model" + "github.com/woodpecker-ci/woodpecker/server/router/middleware/session" + + "github.com/gin-gonic/gin" +) + +// GetOrgPermissions returns the permissions of the current user in the given organization. +func GetOrgPermissions(c *gin.Context) { + var ( + err error + user = session.User(c) + owner = c.Param("owner") + ) + + if user == nil { + c.JSON(http.StatusOK, &model.OrgPerm{}) + return + } + + perm, err := server.Config.Services.Membership.Get(c, user, owner) + if err != nil { + c.String(http.StatusInternalServerError, "Error getting membership for %q. %s", owner, err) + return + } + + c.JSON(http.StatusOK, perm) +} diff --git a/server/api/org_secret.go b/server/api/org_secret.go new file mode 100644 index 000000000..45164212a --- /dev/null +++ b/server/api/org_secret.go @@ -0,0 +1,136 @@ +// Copyright 2022 Woodpecker Authors +// +// 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. + +package api + +import ( + "net/http" + + "github.com/woodpecker-ci/woodpecker/server" + "github.com/woodpecker-ci/woodpecker/server/model" + + "github.com/gin-gonic/gin" +) + +// GetOrgSecret gets the named organization secret from the database +// and writes to the response in json format. +func GetOrgSecret(c *gin.Context) { + var ( + owner = c.Param("owner") + name = c.Param("secret") + ) + secret, err := server.Config.Services.Secrets.OrgSecretFind(owner, name) + if err != nil { + c.String(404, "Error getting org %q secret %q. %s", owner, name, err) + return + } + c.JSON(200, secret.Copy()) +} + +// GetOrgSecretList gest the organization secret list from +// the database and writes to the response in json format. +func GetOrgSecretList(c *gin.Context) { + owner := c.Param("owner") + list, err := server.Config.Services.Secrets.OrgSecretList(owner) + if err != nil { + c.String(http.StatusInternalServerError, "Error getting secret list for %q. %s", owner, err) + return + } + // copy the secret detail to remove the sensitive + // password and token fields. + for i, secret := range list { + list[i] = secret.Copy() + } + c.JSON(http.StatusOK, list) +} + +// PostOrgSecret persists an organization secret to the database. +func PostOrgSecret(c *gin.Context) { + owner := c.Param("owner") + + in := new(model.Secret) + if err := c.Bind(in); err != nil { + c.String(http.StatusBadRequest, "Error parsing org %q secret. %s", owner, err) + return + } + secret := &model.Secret{ + Owner: owner, + Name: in.Name, + Value: in.Value, + Events: in.Events, + Images: in.Images, + } + if err := secret.Validate(); err != nil { + c.String(400, "Error inserting org %q secret. %s", owner, err) + return + } + if err := server.Config.Services.Secrets.OrgSecretCreate(owner, secret); err != nil { + c.String(500, "Error inserting org %q secret %q. %s", owner, in.Name, err) + return + } + c.JSON(200, secret.Copy()) +} + +// PatchOrgSecret updates an organization secret in the database. +func PatchOrgSecret(c *gin.Context) { + var ( + owner = c.Param("owner") + name = c.Param("secret") + ) + + in := new(model.Secret) + err := c.Bind(in) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing secret. %s", err) + return + } + + secret, err := server.Config.Services.Secrets.OrgSecretFind(owner, name) + if err != nil { + c.String(404, "Error getting org %q secret %q. %s", owner, name, err) + return + } + if in.Value != "" { + secret.Value = in.Value + } + if in.Events != nil { + secret.Events = in.Events + } + if in.Images != nil { + secret.Images = in.Images + } + + if err := secret.Validate(); err != nil { + c.String(400, "Error updating org %q secret. %s", owner, err) + return + } + if err := server.Config.Services.Secrets.OrgSecretUpdate(owner, secret); err != nil { + c.String(500, "Error updating org %q secret %q. %s", owner, in.Name, err) + return + } + c.JSON(200, secret.Copy()) +} + +// DeleteOrgSecret deletes the named organization secret from the database. +func DeleteOrgSecret(c *gin.Context) { + var ( + owner = c.Param("owner") + name = c.Param("secret") + ) + if err := server.Config.Services.Secrets.OrgSecretDelete(owner, name); err != nil { + c.String(500, "Error deleting org %q secret %q. %s", owner, name, err) + return + } + c.String(204, "") +} diff --git a/server/api/secret.go b/server/api/repo_secret.go similarity index 100% rename from server/api/secret.go rename to server/api/repo_secret.go diff --git a/server/model/secret.go b/server/model/secret.go index c48344488..9a0a7dac8 100644 --- a/server/model/secret.go +++ b/server/model/secret.go @@ -30,29 +30,47 @@ var ( // SecretService defines a service for managing secrets. type SecretService interface { + SecretListBuild(*Repo, *Build) ([]*Secret, error) + // Repository secrets SecretFind(*Repo, string) (*Secret, error) SecretList(*Repo) ([]*Secret, error) - SecretListBuild(*Repo, *Build) ([]*Secret, error) SecretCreate(*Repo, *Secret) error SecretUpdate(*Repo, *Secret) error SecretDelete(*Repo, string) error + // Organization secrets + OrgSecretFind(string, string) (*Secret, error) + OrgSecretList(string) ([]*Secret, error) + OrgSecretCreate(string, *Secret) error + OrgSecretUpdate(string, *Secret) error + OrgSecretDelete(string, string) error + // Global secrets + GlobalSecretFind(string) (*Secret, error) + GlobalSecretList() ([]*Secret, error) + GlobalSecretCreate(*Secret) error + GlobalSecretUpdate(*Secret) error + GlobalSecretDelete(string) error } // SecretStore persists secret information to storage. type SecretStore interface { SecretFind(*Repo, string) (*Secret, error) - SecretList(*Repo) ([]*Secret, error) + SecretList(*Repo, bool) ([]*Secret, error) SecretCreate(*Secret) error SecretUpdate(*Secret) error SecretDelete(*Secret) error + OrgSecretFind(string, string) (*Secret, error) + OrgSecretList(string) ([]*Secret, error) + GlobalSecretFind(string) (*Secret, error) + GlobalSecretList() ([]*Secret, error) } // Secret represents a secret variable, such as a password or token. // swagger:model registry type Secret struct { ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"` - RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'secret_repo_id'"` - Name string `json:"name" xorm:"UNIQUE(s) INDEX 'secret_name'"` + Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"` + RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"` + Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"` Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"` Images []string `json:"image" xorm:"json 'secret_images'"` Events []WebhookEvent `json:"event" xorm:"json 'secret_events'"` @@ -65,6 +83,16 @@ func (Secret) TableName() string { return "secrets" } +// Global secret. +func (s Secret) Global() bool { + return s.RepoID == 0 && s.Owner == "" +} + +// Organization secret. +func (s Secret) Organization() bool { + return s.RepoID == 0 && s.Owner != "" +} + // Match returns true if an image and event match the restricted list. func (s *Secret) Match(event WebhookEvent) bool { if len(s.Events) == 0 { @@ -119,6 +147,7 @@ func (s *Secret) Validate() error { func (s *Secret) Copy() *Secret { return &Secret{ ID: s.ID, + Owner: s.Owner, RepoID: s.RepoID, Name: s.Name, Images: s.Images, diff --git a/server/plugins/secrets/builtin.go b/server/plugins/secrets/builtin.go index fab49585d..67ae8476e 100644 --- a/server/plugins/secrets/builtin.go +++ b/server/plugins/secrets/builtin.go @@ -21,11 +21,39 @@ func (b *builtin) SecretFind(repo *model.Repo, name string) (*model.Secret, erro } func (b *builtin) SecretList(repo *model.Repo) ([]*model.Secret, error) { - return b.store.SecretList(repo) + return b.store.SecretList(repo, false) } func (b *builtin) SecretListBuild(repo *model.Repo, build *model.Build) ([]*model.Secret, error) { - return b.store.SecretList(repo) + s, err := b.store.SecretList(repo, true) + if err != nil { + return nil, err + } + + // Return only secrets with unique name + // Priority order in case of duplicate names are repository, user/organization, global + secrets := make([]*model.Secret, 0, len(s)) + uniq := make(map[string]struct{}) + for _, cond := range []struct { + Global bool + Organization bool + }{ + {}, + {Organization: true}, + {Global: true}, + } { + for _, secret := range s { + if secret.Global() == cond.Global && secret.Organization() == cond.Organization { + continue + } + if _, ok := uniq[secret.Name]; ok { + continue + } + uniq[secret.Name] = struct{}{} + secrets = append(secrets, secret) + } + } + return secrets, nil } func (b *builtin) SecretCreate(repo *model.Repo, in *model.Secret) error { @@ -43,3 +71,51 @@ func (b *builtin) SecretDelete(repo *model.Repo, name string) error { } return b.store.SecretDelete(secret) } + +func (b *builtin) OrgSecretFind(owner, name string) (*model.Secret, error) { + return b.store.OrgSecretFind(owner, name) +} + +func (b *builtin) OrgSecretList(owner string) ([]*model.Secret, error) { + return b.store.OrgSecretList(owner) +} + +func (b *builtin) OrgSecretCreate(owner string, in *model.Secret) error { + return b.store.SecretCreate(in) +} + +func (b *builtin) OrgSecretUpdate(owner string, in *model.Secret) error { + return b.store.SecretUpdate(in) +} + +func (b *builtin) OrgSecretDelete(owner, name string) error { + secret, err := b.store.OrgSecretFind(owner, name) + if err != nil { + return err + } + return b.store.SecretDelete(secret) +} + +func (b *builtin) GlobalSecretFind(owner string) (*model.Secret, error) { + return b.store.GlobalSecretFind(owner) +} + +func (b *builtin) GlobalSecretList() ([]*model.Secret, error) { + return b.store.GlobalSecretList() +} + +func (b *builtin) GlobalSecretCreate(in *model.Secret) error { + return b.store.SecretCreate(in) +} + +func (b *builtin) GlobalSecretUpdate(in *model.Secret) error { + return b.store.SecretUpdate(in) +} + +func (b *builtin) GlobalSecretDelete(name string) error { + secret, err := b.store.GlobalSecretFind(name) + if err != nil { + return err + } + return b.store.SecretDelete(secret) +} diff --git a/server/router/api.go b/server/router/api.go index 467059de2..6dddc4ac3 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -43,6 +43,21 @@ func apiRoutes(e *gin.Engine) { users.DELETE("/:login", api.DeleteUser) } + orgBase := e.Group("/api/orgs/:owner") + { + orgBase.GET("/permissions", api.GetOrgPermissions) + + org := orgBase.Group("") + { + org.Use(session.MustOrgMember(true)) + org.GET("/secrets", api.GetOrgSecretList) + org.POST("/secrets", api.PostOrgSecret) + org.GET("/secrets/:secret", api.GetOrgSecret) + org.PATCH("/secrets/:secret", api.PatchOrgSecret) + org.DELETE("/secrets/:secret", api.DeleteOrgSecret) + } + } + repoBase := e.Group("/api/repos/:owner/:name") { repoBase.Use(session.SetRepo()) @@ -123,6 +138,16 @@ func apiRoutes(e *gin.Engine) { queue.GET("/norunningbuilds", api.BlockTilQueueHasRunningItem) } + secrets := e.Group("/api/secrets") + { + secrets.Use(session.MustAdmin()) + secrets.GET("", api.GetGlobalSecretList) + secrets.POST("", api.PostGlobalSecret) + secrets.GET("/:secret", api.GetGlobalSecret) + secrets.PATCH("/:secret", api.PatchGlobalSecret) + secrets.DELETE("/:secret", api.DeleteGlobalSecret) + } + debugger := e.Group("/api/debug") { debugger.Use(session.MustAdmin()) diff --git a/server/store/datastore/migration/006_secrets_add_user.go b/server/store/datastore/migration/006_secrets_add_user.go new file mode 100644 index 000000000..0eca42f54 --- /dev/null +++ b/server/store/datastore/migration/006_secrets_add_user.go @@ -0,0 +1,46 @@ +// Copyright 2022 Woodpecker Authors +// +// 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. + +package migration + +import ( + "xorm.io/xorm" +) + +type SecretV006 struct { + Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"` + RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"` + Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"` +} + +// TableName return database table name for xorm +func (SecretV006) TableName() string { + return "secrets" +} + +var alterTableSecretsAddUserCol = task{ + name: "alter-table-add-secrets-user-id", + fn: func(sess *xorm.Session) error { + if err := sess.Sync2(new(SecretV006)); err != nil { + return err + } + if err := alterColumnDefault(sess, "secrets", "secret_repo_id", "0"); err != nil { + return err + } + if err := alterColumnNull(sess, "secrets", "secret_repo_id", false); err != nil { + return err + } + return alterColumnNull(sess, "secrets", "secret_name", false) + }, +} diff --git a/server/store/datastore/migration/common.go b/server/store/datastore/migration/common.go index 000df760a..c3b6dc903 100644 --- a/server/store/datastore/migration/common.go +++ b/server/store/datastore/migration/common.go @@ -212,6 +212,42 @@ func dropTableColumns(sess *xorm.Session, tableName string, columnNames ...strin return nil } +func alterColumnDefault(sess *xorm.Session, table, column, defValue string) error { + dialect := sess.Engine().Dialect().URI().DBType + switch dialect { + case schemas.MYSQL: + _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` COLUMN `%s` SET DEFAULT %s;", table, column, defValue)) + return err + case schemas.POSTGRES: + _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` ALTER COLUMN `%s` SET DEFAULT %s;", table, column, defValue)) + return err + case schemas.SQLITE: + return nil + default: + return fmt.Errorf("dialect '%s' not supported", dialect) + } +} + +func alterColumnNull(sess *xorm.Session, table, column string, null bool) error { + val := "NULL" + if !null { + val = "NOT NULL" + } + dialect := sess.Engine().Dialect().URI().DBType + switch dialect { + case schemas.MYSQL: + _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` COLUMN `%s` SET %s;", table, column, val)) + return err + case schemas.POSTGRES: + _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` ALTER COLUMN `%s` SET %s;", table, column, val)) + return err + case schemas.SQLITE: + return nil + default: + return fmt.Errorf("dialect '%s' not supported", dialect) + } +} + var ( whitespaces = regexp.MustCompile(`\s+`) columnSeparator = regexp.MustCompile(`\s?,\s?`) diff --git a/server/store/datastore/migration/migration.go b/server/store/datastore/migration/migration.go index bc5f6e814..a9f13085a 100644 --- a/server/store/datastore/migration/migration.go +++ b/server/store/datastore/migration/migration.go @@ -34,6 +34,7 @@ var migrationTasks = []*task{ &alterTableReposDropCounter, &dropSenders, &alterTableLogUpdateColumnLogDataType, + &alterTableSecretsAddUserCol, } var allBeans = []interface{}{ diff --git a/server/store/datastore/secret.go b/server/store/datastore/secret.go index d64ad0e27..20feab7bc 100644 --- a/server/store/datastore/secret.go +++ b/server/store/datastore/secret.go @@ -16,6 +16,8 @@ package datastore import ( "github.com/woodpecker-ci/woodpecker/server/model" + + "xorm.io/builder" ) func (s storage) SecretFind(repo *model.Repo, name string) (*model.Secret, error) { @@ -26,9 +28,14 @@ func (s storage) SecretFind(repo *model.Repo, name string) (*model.Secret, error return secret, wrapGet(s.engine.Get(secret)) } -func (s storage) SecretList(repo *model.Repo) ([]*model.Secret, error) { +func (s storage) SecretList(repo *model.Repo, includeGlobalAndOrgSecrets bool) ([]*model.Secret, error) { secrets := make([]*model.Secret, 0, perPage) - return secrets, s.engine.Where("secret_repo_id = ?", repo.ID).Find(&secrets) + var cond builder.Cond = builder.Eq{"secret_repo_id": repo.ID} + if includeGlobalAndOrgSecrets { + cond = cond.Or(builder.Eq{"secret_owner": repo.Owner}). + Or(builder.And(builder.Eq{"secret_owner": ""}, builder.Eq{"secret_repo_id": 0})) + } + return secrets, s.engine.Where(cond).Find(&secrets) } func (s storage) SecretCreate(secret *model.Secret) error { @@ -46,3 +53,28 @@ func (s storage) SecretDelete(secret *model.Secret) error { _, err := s.engine.ID(secret.ID).Delete(new(model.Secret)) return err } + +func (s storage) OrgSecretFind(owner, name string) (*model.Secret, error) { + secret := &model.Secret{ + Owner: owner, + Name: name, + } + return secret, wrapGet(s.engine.Get(secret)) +} + +func (s storage) OrgSecretList(owner string) ([]*model.Secret, error) { + secrets := make([]*model.Secret, 0, perPage) + return secrets, s.engine.Where("secret_owner = ?", owner).Find(&secrets) +} + +func (s storage) GlobalSecretFind(name string) (*model.Secret, error) { + secret := &model.Secret{ + Name: name, + } + return secret, wrapGet(s.engine.Where(builder.And(builder.Eq{"secret_owner": ""}, builder.Eq{"secret_repo_id": 0})).Get(secret)) +} + +func (s storage) GlobalSecretList() ([]*model.Secret, error) { + secrets := make([]*model.Secret, 0, perPage) + return secrets, s.engine.Where(builder.And(builder.Eq{"secret_owner": ""}, builder.Eq{"secret_repo_id": 0})).Find(&secrets) +} diff --git a/server/store/datastore/secret_test.go b/server/store/datastore/secret_test.go index 0285a13cd..66278569d 100644 --- a/server/store/datastore/secret_test.go +++ b/server/store/datastore/secret_test.go @@ -70,22 +70,24 @@ func TestSecretList(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() - assert.NoError(t, store.SecretCreate(&model.Secret{ - RepoID: 1, - Name: "foo", - Value: "bar", - })) - assert.NoError(t, store.SecretCreate(&model.Secret{ - RepoID: 1, - Name: "baz", - Value: "qux", - })) + createTestSecrets(t, store) - list, err := store.SecretList(&model.Repo{ID: 1}) + list, err := store.SecretList(&model.Repo{ID: 1, Owner: "org"}, false) assert.NoError(t, err) assert.Len(t, list, 2) } +func TestSecretBuildList(t *testing.T) { + store, closer := newTestStore(t, new(model.Secret)) + defer closer() + + createTestSecrets(t, store) + + list, err := store.SecretList(&model.Repo{ID: 1, Owner: "org"}, true) + assert.NoError(t, err) + assert.Len(t, list, 4) +} + func TestSecretUpdate(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() @@ -162,3 +164,135 @@ func TestSecretIndexes(t *testing.T) { t.Errorf("Unexpected error: duplicate name") } } + +func createTestSecrets(t *testing.T, store *storage) { + assert.NoError(t, store.SecretCreate(&model.Secret{ + Owner: "org", + Name: "usr", + Value: "sec", + })) + assert.NoError(t, store.SecretCreate(&model.Secret{ + RepoID: 1, + Name: "foo", + Value: "bar", + })) + assert.NoError(t, store.SecretCreate(&model.Secret{ + RepoID: 1, + Name: "baz", + Value: "qux", + })) + assert.NoError(t, store.SecretCreate(&model.Secret{ + Name: "global", + Value: "val", + })) +} + +func TestOrgSecretFind(t *testing.T) { + store, closer := newTestStore(t, new(model.Secret)) + defer closer() + + err := store.SecretCreate(&model.Secret{ + Owner: "org", + Name: "password", + Value: "correct-horse-battery-staple", + Images: []string{"golang", "node"}, + Events: []model.WebhookEvent{"push", "tag"}, + }) + if err != nil { + t.Errorf("Unexpected error: insert secret: %s", err) + return + } + + secret, err := store.OrgSecretFind("org", "password") + if err != nil { + t.Error(err) + return + } + if got, want := secret.Owner, "org"; got != want { + t.Errorf("Want owner %s, got %s", want, got) + } + if got, want := secret.Name, "password"; got != want { + t.Errorf("Want secret name %s, got %s", want, got) + } + if got, want := secret.Value, "correct-horse-battery-staple"; got != want { + t.Errorf("Want secret value %s, got %s", want, got) + } + if got, want := secret.Events[0], model.EventPush; got != want { + t.Errorf("Want secret event %s, got %s", want, got) + } + if got, want := secret.Events[1], model.EventTag; got != want { + t.Errorf("Want secret event %s, got %s", want, got) + } + if got, want := secret.Images[0], "golang"; got != want { + t.Errorf("Want secret image %s, got %s", want, got) + } + if got, want := secret.Images[1], "node"; got != want { + t.Errorf("Want secret image %s, got %s", want, got) + } +} + +func TestOrgSecretList(t *testing.T) { + store, closer := newTestStore(t, new(model.Secret)) + defer closer() + + createTestSecrets(t, store) + + list, err := store.OrgSecretList("org") + assert.NoError(t, err) + assert.Len(t, list, 1) + + assert.True(t, list[0].Organization()) +} + +func TestGlobalSecretFind(t *testing.T) { + store, closer := newTestStore(t, new(model.Secret)) + defer closer() + + err := store.SecretCreate(&model.Secret{ + Name: "password", + Value: "correct-horse-battery-staple", + Images: []string{"golang", "node"}, + Events: []model.WebhookEvent{"push", "tag"}, + }) + if err != nil { + t.Errorf("Unexpected error: insert secret: %s", err) + return + } + + secret, err := store.GlobalSecretFind("password") + if err != nil { + t.Error(err) + return + } + if got, want := secret.Name, "password"; got != want { + t.Errorf("Want secret name %s, got %s", want, got) + } + if got, want := secret.Value, "correct-horse-battery-staple"; got != want { + t.Errorf("Want secret value %s, got %s", want, got) + } + if got, want := secret.Events[0], model.EventPush; got != want { + t.Errorf("Want secret event %s, got %s", want, got) + } + if got, want := secret.Events[1], model.EventTag; got != want { + t.Errorf("Want secret event %s, got %s", want, got) + } + if got, want := secret.Images[0], "golang"; got != want { + t.Errorf("Want secret image %s, got %s", want, got) + } + if got, want := secret.Images[1], "node"; got != want { + t.Errorf("Want secret image %s, got %s", want, got) + } +} + +func TestGlobalSecretList(t *testing.T) { + store, closer := newTestStore(t, new(model.Secret)) + defer closer() + + createTestSecrets(t, store) + + list, err := store.GlobalSecretList() + assert.NoError(t, err) + assert.Len(t, list, 1) + + assert.True(t, list[0].Global()) +} diff --git a/server/store/store.go b/server/store/store.go index 2279983bf..5cd32b14e 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -106,10 +106,14 @@ type Store interface { // Secrets SecretFind(*model.Repo, string) (*model.Secret, error) - SecretList(*model.Repo) ([]*model.Secret, error) + SecretList(*model.Repo, bool) ([]*model.Secret, error) SecretCreate(*model.Secret) error SecretUpdate(*model.Secret) error SecretDelete(*model.Secret) error + OrgSecretFind(string, string) (*model.Secret, error) + OrgSecretList(string) ([]*model.Secret, error) + GlobalSecretFind(string) (*model.Secret, error) + GlobalSecretList() ([]*model.Secret, error) // Registrys RegistryFind(*model.Repo, string) (*model.Registry, error) diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 1f3fc46b3..dd6d80c64 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -31,7 +31,7 @@ "branches": "Branches", "add": "Add repository", "user_none": "This organization / user does not have any projects yet.", - "not_allowed": "Not allowed to access this repository", + "not_allowed": "You are not allowed to access this repository", "enable": { "reload": "Reload repositories", @@ -43,7 +43,7 @@ "settings": { "settings": "Settings", - "not_allowed": "Not allowed to access this repository's settings", + "not_allowed": "You are not allowed to access this repository's settings", "general": { "general": "General", @@ -205,6 +205,67 @@ } }, + "org": { + "settings": { + "settings": "Settings", + "not_allowed": "You are not allowed to access this organization's settings", + + "secrets": { + "secrets": "Secrets", + "desc": "Organization secrets can be passed to all organization's repository individual pipeline steps at runtime as environmental variables.", + "none": "There are no organization secrets yet.", + "add": "Add secret", + "save": "Save secret", + "show": "Show secrets", + "name": "Name", + "value": "Value", + "deleted": "Organization secret deleted", + "created": "Organization secret created", + "saved": "Organization secret saved", + + "images": { + "images": "Available for following images", + "desc": "Comma separated list of images where this secret is available, leave empty to allow all images" + }, + "events": { + "events": "Available at following events", + "pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets." + } + } + } + }, + + "admin": { + "settings": { + "settings": "Settings", + "not_allowed": "You are not allowed to access server settings", + + "secrets": { + "secrets": "Secrets", + "desc": "Global secrets can be passed to all repositories individual pipeline steps at runtime as environmental variables.", + "warning": "These secrets will be available for all server users.", + "none": "There are no global secrets yet.", + "add": "Add secret", + "save": "Save secret", + "show": "Show secrets", + "name": "Name", + "value": "Value", + "deleted": "Global secret deleted", + "created": "Global secret created", + "saved": "Global secret saved", + + "images": { + "images": "Available for following images", + "desc": "Comma separated list of images where this secret is available, leave empty to allow all images" + }, + "events": { + "events": "Available at following events", + "pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets." + } + } + } + }, + "user": { "oauth_error": "Error while authenticating against OAuth provider", "internal_error": "Some internal error occurred", diff --git a/web/src/assets/locales/lv.json b/web/src/assets/locales/lv.json index 798028f30..f19d26b2d 100644 --- a/web/src/assets/locales/lv.json +++ b/web/src/assets/locales/lv.json @@ -205,6 +205,67 @@ } }, + "org": { + "settings": { + "settings": "Iestatījumi", + "not_allowed": "Nav piekļuves šīs organizācijas iestatījumiem", + + "secrets": { + "secrets": "Noslēpumi", + "desc": "Noslēpumus var padot visu organizācijas repozitoriju individuāliem konvejerdarba soļiem izpildes laikā kā vides mainīgos.", + "none": "Pagaidām nav neviena organizācijas noslēpuma.", + "add": "Pievienot noslēpumu", + "save": "Saglabāt noslēpumu", + "show": "Noslēpumu saraksts", + "name": "Nosaukums", + "value": "Vērtība", + "deleted": "Organizācijas noslēpums dzēsts", + "created": "Organizācijas noslēpums izveidots", + "saved": "Organizācijas noslēpums saglabāts", + + "images": { + "images": "Pieejami šādiem attēliem", + "desc": "Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem." + }, + "events": { + "events": "Pieejams šādiem notikumiem", + "pr_warning": "Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu!" + } + } + } + }, + + "admin": { + "settings": { + "settings": "Iestatījumi", + "not_allowed": "Nav piekļuves servera iestatījumiem", + + "secrets": { + "secrets": "Noslēpumi", + "desc": "Noslēpumus var padot visu repozitoriju individuāliem konvejerdarba soļiem izpildes laikā kā vides mainīgos.", + "warning": "Šie noslēpumi būs pieejami visiem servera lietotājiem.", + "none": "Pagaidām nav neviena globālā noslēpuma.", + "add": "Pievienot noslēpumu", + "save": "Saglabāt noslēpumu", + "show": "Noslēpumu saraksts", + "name": "Nosaukums", + "value": "Vērtība", + "deleted": "Globālais noslēpums dzēsts", + "created": "Globālais noslēpums izveidots", + "saved": "Globālais noslēpums saglabāts", + + "images": { + "images": "Pieejami šādiem attēliem", + "desc": "Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem." + }, + "events": { + "events": "Pieejams šādiem notikumiem", + "pr_warning": "Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu!" + } + } + } + }, + "user": { "oauth_error": "Neizdevās autorizēties, izmantojot, OAuth piegādātāju", "internal_error": "Notikusi sistēmas iekšējā kļūda", diff --git a/web/src/components/admin/settings/AdminSecretsTab.vue b/web/src/components/admin/settings/AdminSecretsTab.vue new file mode 100644 index 000000000..fb7c95291 --- /dev/null +++ b/web/src/components/admin/settings/AdminSecretsTab.vue @@ -0,0 +1,143 @@ + + + diff --git a/web/src/components/atomic/Warning.vue b/web/src/components/atomic/Warning.vue new file mode 100644 index 000000000..0b8f4431b --- /dev/null +++ b/web/src/components/atomic/Warning.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/src/components/layout/header/Navbar.vue b/web/src/components/layout/header/Navbar.vue index 429e79185..9d9033de9 100644 --- a/web/src/components/layout/header/Navbar.vue +++ b/web/src/components/layout/header/Navbar.vue @@ -26,6 +26,12 @@ class="!text-white !dark:text-gray-500" @click="darkMode = !darkMode" /> + diff --git a/web/src/components/org/settings/OrgSecretsTab.vue b/web/src/components/org/settings/OrgSecretsTab.vue new file mode 100644 index 000000000..8595ba977 --- /dev/null +++ b/web/src/components/org/settings/OrgSecretsTab.vue @@ -0,0 +1,147 @@ + + + diff --git a/web/src/components/repo/settings/SecretsTab.vue b/web/src/components/repo/settings/SecretsTab.vue index e47eb0f28..0b78b0b01 100644 --- a/web/src/components/repo/settings/SecretsTab.vue +++ b/web/src/components/repo/settings/SecretsTab.vue @@ -18,64 +18,22 @@