-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
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
Secrets storage with SecretKey encrypted #22142
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.