- 
          
- 
                Notifications
    You must be signed in to change notification settings 
- Fork 6.2k
Secrets storage with SecretKey encrypted #22142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6b24ada
              47c472a
              64a91b4
              9f2d204
              368c97e
              d39f7c4
              9465c13
              eb2b139
              f4df973
              abde871
              b1e8915
              a077ee2
              5b1044a
              834c7c1
              8e1291d
              1788de5
              0a3be05
              a0ebf78
              319da15
              b08114b
              073656c
              f89bd80
              850d936
              41310a7
              e54785a
              ccb57c8
              e30f532
              29e4f6b
              b01b2a9
              a1aee64
              4c8f590
              bc999bd
              41e9be0
              2c7ae0c
              dd84d07
              f5effc1
              c08fc15
              44ca6bf
              6fcb7bf
              b79b156
              acc0c12
              c754525
              eb5bcec
              3183368
              e86e30f
              e6cee41
              641d37a
              7c82f7a
              f9d58d4
              aa10928
              f738069
              5103f1d
              a23241f
              9f8fdaa
              b32bb7a
              23dd7a7
              a8c192d
              f1ef5ae
              4a2676e
              5aa55fe
              d1a729b
              00f305f
              b1a1926
              6352031
              402c8aa
              caecad3
              9139775
              91a3048
              343c3b4
              b828e3b
              df8ad92
              ab58816
              2b745d0
              674fced
              7b241ca
              031ac08
              4753a9b
              6c11bd8
              b4f6063
              7bc76de
              b398215
              5562518
              6ebc38e
              3c26505
              e74572f
              752d8e5
              eba6dc5
              762a23c
              1bf0e15
              1c415f9
              0f9e2a6
              0ae9a05
              5395a56
              524561d
              4478b5e
              32cae8e
              5652a6d
              8b9837a
              4f266c1
              aceb513
              e587971
              401b6a7
              0529b1c
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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. | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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)) | ||
| } | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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) | ||
| } | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
|  | @@ -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") | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alternatively, we could also delete by name, repo, and owner. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure, maybe we can provide batch deletion later. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That wasn't what I meant: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @delvh While I agree with you, usually the user will never see this request because it is just a POST from the overview page. So the user will not see the called url. | ||
| 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 { | ||
|  | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I said on the previous PR:
I think it would be a good idea to add
ctx.Data["UserSecrets"] = FindSecrets(ctx, opts{OwnerID: ctx.Owner.ID})(pseudo code)so that we can also display read-only the user/org secrets that are already defined, and link to the corresponding settings page.
However, this can also be postponed for later if necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's a good idea, but let's do it later.