diff --git a/docs/content/doc/secrets/overview.en-us.md b/docs/content/doc/secrets/overview.en-us.md new file mode 100644 index 000000000..1a88d6cfb --- /dev/null +++ b/docs/content/doc/secrets/overview.en-us.md @@ -0,0 +1,36 @@ +--- +date: "2022-12-19T21:26:00+08:00" +title: "Encrypted secrets" +slug: "secrets/overview" +draft: false +toc: false +menu: + sidebar: + parent: "secrets" + name: "Overview" + weight: 1 + identifier: "overview" +--- + +# Encrypted secrets + +Encrypted secrets allow you to store sensitive information in your organization or repository. +Secrets are available on Gitea 1.19+. + +# Naming your secrets + +The following rules apply to secret names: + +Secret names can only contain alphanumeric characters (`[a-z]`, `[A-Z]`, `[0-9]`) or underscores (`_`). Spaces are not allowed. + +Secret names must not start with the `GITHUB_` and `GITEA_` prefix. + +Secret names must not start with a number. + +Secret names are not case-sensitive. + +Secret names must be unique at the level they are created at. + +For example, a secret created at the repository level must have a unique name in that repository, and a secret created at the organization level must have a unique name at that level. + +If a secret with the same name exists at multiple levels, the secret at the lowest level takes precedence. For example, if an organization-level secret has the same name as a repository-level secret, then the repository-level secret takes precedence. diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index e718355f8..591bfa3e8 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -442,6 +442,8 @@ var migrations = []Migration{ NewMigration("Add package cleanup rule table", v1_19.CreatePackageCleanupRuleTable), // v235 -> v236 NewMigration("Add index for access_token", v1_19.AddIndexForAccessToken), + // v236 -> v237 + NewMigration("Create secrets table", v1_19.CreateSecretsTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_19/v236.go b/models/migrations/v1_19/v236.go new file mode 100644 index 000000000..f172a85b1 --- /dev/null +++ b/models/migrations/v1_19/v236.go @@ -0,0 +1,23 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_19 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateSecretsTable(x *xorm.Engine) error { + type Secret struct { + ID int64 + OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"` + RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"` + Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"` + Data string `xorm:"LONGTEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + } + + return x.Sync(new(Secret)) +} diff --git a/models/organization/org.go b/models/organization/org.go index b3d77b4ec..9d9e9cda4 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" @@ -370,6 +371,7 @@ func DeleteOrganization(ctx context.Context, org *Organization) error { &TeamUser{OrgID: org.ID}, &TeamUnit{OrgID: org.ID}, &TeamInvite{OrgID: org.ID}, + &secret_model.Secret{OwnerID: org.ID}, ); err != nil { return fmt.Errorf("DeleteBeans: %w", err) } diff --git a/models/repo.go b/models/repo.go index 9af600c9b..e95887077 100644 --- a/models/repo.go +++ b/models/repo.go @@ -21,6 +21,7 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" system_model "code.gitea.io/gitea/models/system" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -150,6 +151,7 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error { &admin_model.Task{RepoID: repoID}, &repo_model.Watch{RepoID: repoID}, &webhook.Webhook{RepoID: repoID}, + &secret_model.Secret{RepoID: repoID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/models/secret/secret.go b/models/secret/secret.go new file mode 100644 index 000000000..f970d5319 --- /dev/null +++ b/models/secret/secret.go @@ -0,0 +1,124 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package secret + +import ( + "context" + "fmt" + "regexp" + "strings" + + "code.gitea.io/gitea/models/db" + secret_module "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +type ErrSecretInvalidValue struct { + Name *string + Data *string +} + +func (err ErrSecretInvalidValue) Error() string { + if err.Name != nil { + return fmt.Sprintf("secret name %q is invalid", *err.Name) + } + if err.Data != nil { + return fmt.Sprintf("secret data %q is invalid", *err.Data) + } + return util.ErrInvalidArgument.Error() +} + +func (err ErrSecretInvalidValue) Unwrap() error { + return util.ErrInvalidArgument +} + +// Secret represents a secret +type Secret struct { + ID int64 + OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"` + RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"` + Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"` + Data string `xorm:"LONGTEXT"` // encrypted data + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` +} + +// newSecret Creates a new already encrypted secret +func newSecret(ownerID, repoID int64, name, data string) *Secret { + return &Secret{ + OwnerID: ownerID, + RepoID: repoID, + Name: strings.ToUpper(name), + Data: data, + } +} + +// InsertEncryptedSecret Creates, encrypts, and validates a new secret with yet unencrypted data and insert into database +func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, data string) (*Secret, error) { + encrypted, err := secret_module.EncryptSecret(setting.SecretKey, strings.TrimSpace(data)) + if err != nil { + return nil, err + } + secret := newSecret(ownerID, repoID, name, encrypted) + if err := secret.Validate(); err != nil { + return secret, err + } + return secret, db.Insert(ctx, secret) +} + +func init() { + db.RegisterModel(new(Secret)) +} + +var ( + secretNameReg = regexp.MustCompile("^[A-Z_][A-Z0-9_]*$") + forbiddenSecretPrefixReg = regexp.MustCompile("^GIT(EA|HUB)_") +) + +// Validate validates the required fields and formats. +func (s *Secret) Validate() error { + switch { + case len(s.Name) == 0 || len(s.Name) > 50: + return ErrSecretInvalidValue{Name: &s.Name} + case len(s.Data) == 0: + return ErrSecretInvalidValue{Data: &s.Data} + case !secretNameReg.MatchString(s.Name) || + forbiddenSecretPrefixReg.MatchString(s.Name): + return ErrSecretInvalidValue{Name: &s.Name} + default: + return nil + } +} + +type FindSecretsOptions struct { + db.ListOptions + OwnerID int64 + RepoID int64 +} + +func (opts *FindSecretsOptions) toConds() builder.Cond { + cond := builder.NewCond() + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + + return cond +} + +func FindSecrets(ctx context.Context, opts FindSecretsOptions) ([]*Secret, error) { + var secrets []*Secret + sess := db.GetEngine(ctx) + if opts.PageSize != 0 { + sess = db.SetSessionPagination(sess, &opts.ListOptions) + } + return secrets, sess. + Where(opts.toConds()). + Find(&secrets) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 70f982b8d..dd31562c3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3212,3 +3212,19 @@ owner.settings.cleanuprules.remove.days = Remove versions older than owner.settings.cleanuprules.remove.pattern = Remove versions matching owner.settings.cleanuprules.success.update = Cleanup rule has been updated. owner.settings.cleanuprules.success.delete = Cleanup rule has been deleted. + +[secrets] +secrets = Secrets +description = Secrets will be passed to certain actions and cannot be read otherwise. +none = There are no secrets yet. +value = Value +name = Name +creation = Add Secret +creation.name_placeholder = case-insensitive, alphanumeric characters or underscores only, cannot start with GITEA_ or GITHUB_ +creation.value_placeholder = Input any content. Whitespace at the start and end will be omitted. +creation.success = The secret '%s' has been added. +creation.failed = Failed to add secret. +deletion = Remove secret +deletion.description = Removing a secret will revoke its access to repositories. Continue? +deletion.success = The secret has been removed. +deletion.failed = Failed to remove secret. diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 899e554ba..e625962f7 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" @@ -37,6 +38,8 @@ const ( tplSettingsHooks base.TplName = "org/settings/hooks" // tplSettingsLabels template path for render labels settings tplSettingsLabels base.TplName = "org/settings/labels" + // tplSettingsSecrets template path for render secrets settings + tplSettingsSecrets base.TplName = "org/settings/secrets" ) // Settings render the main settings page @@ -246,3 +249,51 @@ func Labels(ctx *context.Context) { ctx.Data["LabelTemplates"] = repo_module.LabelTemplates ctx.HTML(http.StatusOK, tplSettingsLabels) } + +// Secrets render organization secrets page +func Secrets(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.secrets") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsOrgSettingsSecrets"] = true + + secrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{OwnerID: ctx.Org.Organization.ID}) + if err != nil { + ctx.ServerError("FindSecrets", err) + return + } + ctx.Data["Secrets"] = secrets + + ctx.HTML(http.StatusOK, tplSettingsSecrets) +} + +// SecretsPost add secrets +func SecretsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddSecretForm) + + _, err := secret_model.InsertEncryptedSecret(ctx, ctx.Org.Organization.ID, 0, form.Title, form.Content) + if err != nil { + ctx.Flash.Error(ctx.Tr("secrets.creation.failed")) + log.Error("validate secret: %v", err) + ctx.Redirect(ctx.Org.OrgLink + "/settings/secrets") + return + } + + log.Trace("Org %d: secret added", ctx.Org.Organization.ID) + ctx.Flash.Success(ctx.Tr("secrets.creation.success", form.Title)) + ctx.Redirect(ctx.Org.OrgLink + "/settings/secrets") +} + +// SecretsDelete delete secrets +func SecretsDelete(ctx *context.Context) { + id := ctx.FormInt64("id") + if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id}); err != nil { + ctx.Flash.Error(ctx.Tr("secrets.deletion.failed")) + log.Error("delete secret %d: %v", id, err) + } else { + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Org.OrgLink + "/settings/secrets", + }) +} diff --git a/routers/web/repo/setting.go b/routers/web/repo/setting.go index f35adcaa1..913ed6c7c 100644 --- a/routers/web/repo/setting.go +++ b/routers/web/repo/setting.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" @@ -1113,12 +1114,37 @@ func DeployKeys(ctx *context.Context) { } ctx.Data["Deploykeys"] = keys + secrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{RepoID: ctx.Repo.Repository.ID}) + if err != nil { + ctx.ServerError("FindSecrets", err) + return + } + ctx.Data["Secrets"] = secrets + ctx.HTML(http.StatusOK, tplDeployKeys) } +// SecretsPost response for creating a new secret +func SecretsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AddSecretForm) + + _, err := secret_model.InsertEncryptedSecret(ctx, 0, ctx.Repo.Repository.ID, form.Title, form.Content) + if err != nil { + ctx.Flash.Error(ctx.Tr("secrets.creation.failed")) + log.Error("validate secret: %v", err) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") + return + } + + log.Trace("Secret added: %d", ctx.Repo.Repository.ID) + ctx.Flash.Success(ctx.Tr("secrets.creation.success", form.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") +} + // DeployKeysPost response for adding a deploy key of a repository func DeployKeysPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.AddKeyForm) + ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") ctx.Data["PageIsSettingsKeys"] = true ctx.Data["DisableSSH"] = setting.SSH.Disabled @@ -1177,6 +1203,20 @@ func DeployKeysPost(ctx *context.Context) { ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") } +func DeleteSecret(ctx *context.Context) { + id := ctx.FormInt64("id") + if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id}); err != nil { + ctx.Flash.Error(ctx.Tr("secrets.deletion.failed")) + log.Error("delete secret %d: %v", id, err) + } else { + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/settings/keys", + }) +} + // DeleteDeployKey response for deleting a deploy key func DeleteDeployKey(ctx *context.Context) { if err := asymkey_service.DeleteDeployKey(ctx.Doer, ctx.FormInt64("id")); err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index f9d97758a..20d067a16 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -774,6 +774,12 @@ func RegisterRoutes(m *web.Route) { m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), org.InitializeLabels) }) + m.Group("/secrets", func() { + m.Get("", org.Secrets) + m.Post("", web.Bind(forms.AddSecretForm{}), org.SecretsPost) + m.Post("/delete", org.SecretsDelete) + }) + m.Route("/delete", "GET,POST", org.SettingsDelete) m.Group("/packages", func() { @@ -912,6 +918,10 @@ func RegisterRoutes(m *web.Route) { m.Combo("").Get(repo.DeployKeys). Post(web.Bind(forms.AddKeyForm{}), repo.DeployKeysPost) m.Post("/delete", repo.DeleteDeployKey) + m.Group("/secrets", func() { + m.Post("", web.Bind(forms.AddSecretForm{}), repo.SecretsPost) + m.Post("/delete", repo.DeleteSecret) + }) }) m.Group("/lfs", func() { diff --git a/services/forms/user_form.go b/services/forms/user_form.go index cd2c45261..bbea58310 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -363,6 +363,18 @@ func (f *AddKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Er return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// AddSecretForm for adding secrets +type AddSecretForm struct { + Title string `binding:"Required;MaxSize(50)"` + Content string `binding:"Required"` +} + +// Validate validates the fields +func (f *AddSecretForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // NewAccessTokenForm form for creating access token type NewAccessTokenForm struct { Name string `binding:"Required;MaxSize(255)"` diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 9ff30ae4f..765bb6aaa 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -12,6 +12,9 @@ {{.locale.Tr "repo.labels"}} + + {{.locale.Tr "secrets.secrets"}} + {{if .EnableOAuth2}} {{.locale.Tr "settings.applications"}} diff --git a/templates/org/settings/secrets.tmpl b/templates/org/settings/secrets.tmpl new file mode 100644 index 000000000..dd2a437b7 --- /dev/null +++ b/templates/org/settings/secrets.tmpl @@ -0,0 +1,83 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+
+ {{template "org/settings/navbar" .}} +
+ {{template "base/alert" .}} +

+ {{.locale.Tr "secrets.secrets"}} +
+
{{.locale.Tr "secrets.creation"}}
+
+

+
+
+
+ {{.CsrfTokenHtml}} +
+ {{.locale.Tr "secrets.description"}} +
+
+ + +
+
+ + +
+ + +
+
+ {{if .Secrets}} +
+ {{range .Secrets}} +
+
+ +
+
+ {{svg "octicon-key" 32}} +
+
+ {{.Name}} +
******
+
+ + {{$.locale.Tr "settings.add_on"}} + {{.CreatedUnix.FormatShort}} + +
+
+
+ {{end}} +
+ {{else}} + {{.locale.Tr "secrets.none"}} + {{end}} +
+
+
+
+
+ + + +{{template "base/footer" .}} diff --git a/templates/repo/settings/deploy_keys.tmpl b/templates/repo/settings/deploy_keys.tmpl index 44c916eef..31d1c1f7a 100644 --- a/templates/repo/settings/deploy_keys.tmpl +++ b/templates/repo/settings/deploy_keys.tmpl @@ -75,6 +75,8 @@ {{end}} +
+ {{template "repo/settings/secrets" .}}
diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index e2b741b8d..236a82f34 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -25,7 +25,7 @@ {{end}} - {{.locale.Tr "repo.settings.deploy_keys"}} + {{.locale.Tr "secrets.secrets"}} {{if .LFSStartServer}} diff --git a/templates/repo/settings/secrets.tmpl b/templates/repo/settings/secrets.tmpl new file mode 100644 index 000000000..6fb97beb4 --- /dev/null +++ b/templates/repo/settings/secrets.tmpl @@ -0,0 +1,60 @@ +
+

+ {{.locale.Tr "secrets.secrets"}} +
+
{{.locale.Tr "secrets.creation"}}
+
+

+
+
+
+ {{.CsrfTokenHtml}} +
+ {{.locale.Tr "secrets.description"}} +
+
+ + +
+
+ + +
+ + +
+
+ {{if .Secrets}} +
+ {{range .Secrets}} +
+
+ +
+
+ {{svg "octicon-key" 32}} +
+
+ {{.Name}} +
******
+
+ + {{$.locale.Tr "settings.add_on"}} + {{.CreatedUnix.FormatShort}} + +
+
+
+ {{end}} +
+ {{else}} + {{.locale.Tr "secrets.none"}} + {{end}} +
+