Skip to content

Commit 6590551

Browse files
wolfogrelafrikslunnywxiaoguangdelvh
authored
Secrets storage with SecretKey encrypted (#22142)
Fork of #14483, but [gave up MasterKey](#14483 (comment)), and fixed some problems. Close #12065. Needed by #13539. Featrues: - Secrets for repo and org, not user yet. - Use SecretKey to encrypte/encrypt secrets. - Trim spaces of secret value. - Add a new locale ini block, to make it easy to support secrets for user. Snapshots: Repo level secrets: ![image](https://user-images.githubusercontent.com/9418365/207823319-b8a4903f-38ca-4af7-9d05-336a5af906f3.png) Rrg level secrets ![image](https://user-images.githubusercontent.com/9418365/207823371-8bd02e93-1928-40d1-8c76-f48b255ace36.png) Co-authored-by: Lauris BH <[email protected]> Co-authored-by: Lunny Xiao <[email protected]> Co-authored-by: wxiaoguang <[email protected]> Co-authored-by: delvh <[email protected]> Co-authored-by: KN4CK3R <[email protected]>
1 parent 40ba750 commit 6590551

File tree

17 files changed

+468
-2
lines changed

17 files changed

+468
-2
lines changed
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
date: "2022-12-19T21:26:00+08:00"
3+
title: "Encrypted secrets"
4+
slug: "secrets/overview"
5+
draft: false
6+
toc: false
7+
menu:
8+
sidebar:
9+
parent: "secrets"
10+
name: "Overview"
11+
weight: 1
12+
identifier: "overview"
13+
---
14+
15+
# Encrypted secrets
16+
17+
Encrypted secrets allow you to store sensitive information in your organization or repository.
18+
Secrets are available on Gitea 1.19+.
19+
20+
# Naming your secrets
21+
22+
The following rules apply to secret names:
23+
24+
Secret names can only contain alphanumeric characters (`[a-z]`, `[A-Z]`, `[0-9]`) or underscores (`_`). Spaces are not allowed.
25+
26+
Secret names must not start with the `GITHUB_` and `GITEA_` prefix.
27+
28+
Secret names must not start with a number.
29+
30+
Secret names are not case-sensitive.
31+
32+
Secret names must be unique at the level they are created at.
33+
34+
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.
35+
36+
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.

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,8 @@ var migrations = []Migration{
442442
NewMigration("Add package cleanup rule table", v1_19.CreatePackageCleanupRuleTable),
443443
// v235 -> v236
444444
NewMigration("Add index for access_token", v1_19.AddIndexForAccessToken),
445+
// v236 -> v237
446+
NewMigration("Create secrets table", v1_19.CreateSecretsTable),
445447
}
446448

447449
// GetCurrentDBVersion returns the current db version

models/migrations/v1_19/v236.go

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_19 //nolint
5+
6+
import (
7+
"code.gitea.io/gitea/modules/timeutil"
8+
9+
"xorm.io/xorm"
10+
)
11+
12+
func CreateSecretsTable(x *xorm.Engine) error {
13+
type Secret struct {
14+
ID int64
15+
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
16+
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"`
17+
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"`
18+
Data string `xorm:"LONGTEXT"`
19+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
20+
}
21+
22+
return x.Sync(new(Secret))
23+
}

models/organization/org.go

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"code.gitea.io/gitea/models/db"
1313
"code.gitea.io/gitea/models/perm"
1414
repo_model "code.gitea.io/gitea/models/repo"
15+
secret_model "code.gitea.io/gitea/models/secret"
1516
"code.gitea.io/gitea/models/unit"
1617
user_model "code.gitea.io/gitea/models/user"
1718
"code.gitea.io/gitea/modules/log"
@@ -370,6 +371,7 @@ func DeleteOrganization(ctx context.Context, org *Organization) error {
370371
&TeamUser{OrgID: org.ID},
371372
&TeamUnit{OrgID: org.ID},
372373
&TeamInvite{OrgID: org.ID},
374+
&secret_model.Secret{OwnerID: org.ID},
373375
); err != nil {
374376
return fmt.Errorf("DeleteBeans: %w", err)
375377
}

models/repo.go

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
access_model "code.gitea.io/gitea/models/perm/access"
2222
project_model "code.gitea.io/gitea/models/project"
2323
repo_model "code.gitea.io/gitea/models/repo"
24+
secret_model "code.gitea.io/gitea/models/secret"
2425
system_model "code.gitea.io/gitea/models/system"
2526
"code.gitea.io/gitea/models/unit"
2627
user_model "code.gitea.io/gitea/models/user"
@@ -150,6 +151,7 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error {
150151
&admin_model.Task{RepoID: repoID},
151152
&repo_model.Watch{RepoID: repoID},
152153
&webhook.Webhook{RepoID: repoID},
154+
&secret_model.Secret{RepoID: repoID},
153155
); err != nil {
154156
return fmt.Errorf("deleteBeans: %w", err)
155157
}

models/secret/secret.go

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package secret
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"regexp"
10+
"strings"
11+
12+
"code.gitea.io/gitea/models/db"
13+
secret_module "code.gitea.io/gitea/modules/secret"
14+
"code.gitea.io/gitea/modules/setting"
15+
"code.gitea.io/gitea/modules/timeutil"
16+
"code.gitea.io/gitea/modules/util"
17+
18+
"xorm.io/builder"
19+
)
20+
21+
type ErrSecretInvalidValue struct {
22+
Name *string
23+
Data *string
24+
}
25+
26+
func (err ErrSecretInvalidValue) Error() string {
27+
if err.Name != nil {
28+
return fmt.Sprintf("secret name %q is invalid", *err.Name)
29+
}
30+
if err.Data != nil {
31+
return fmt.Sprintf("secret data %q is invalid", *err.Data)
32+
}
33+
return util.ErrInvalidArgument.Error()
34+
}
35+
36+
func (err ErrSecretInvalidValue) Unwrap() error {
37+
return util.ErrInvalidArgument
38+
}
39+
40+
// Secret represents a secret
41+
type Secret struct {
42+
ID int64
43+
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
44+
RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL DEFAULT 0"`
45+
Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"`
46+
Data string `xorm:"LONGTEXT"` // encrypted data
47+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
48+
}
49+
50+
// newSecret Creates a new already encrypted secret
51+
func newSecret(ownerID, repoID int64, name, data string) *Secret {
52+
return &Secret{
53+
OwnerID: ownerID,
54+
RepoID: repoID,
55+
Name: strings.ToUpper(name),
56+
Data: data,
57+
}
58+
}
59+
60+
// InsertEncryptedSecret Creates, encrypts, and validates a new secret with yet unencrypted data and insert into database
61+
func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, data string) (*Secret, error) {
62+
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, strings.TrimSpace(data))
63+
if err != nil {
64+
return nil, err
65+
}
66+
secret := newSecret(ownerID, repoID, name, encrypted)
67+
if err := secret.Validate(); err != nil {
68+
return secret, err
69+
}
70+
return secret, db.Insert(ctx, secret)
71+
}
72+
73+
func init() {
74+
db.RegisterModel(new(Secret))
75+
}
76+
77+
var (
78+
secretNameReg = regexp.MustCompile("^[A-Z_][A-Z0-9_]*$")
79+
forbiddenSecretPrefixReg = regexp.MustCompile("^GIT(EA|HUB)_")
80+
)
81+
82+
// Validate validates the required fields and formats.
83+
func (s *Secret) Validate() error {
84+
switch {
85+
case len(s.Name) == 0 || len(s.Name) > 50:
86+
return ErrSecretInvalidValue{Name: &s.Name}
87+
case len(s.Data) == 0:
88+
return ErrSecretInvalidValue{Data: &s.Data}
89+
case !secretNameReg.MatchString(s.Name) ||
90+
forbiddenSecretPrefixReg.MatchString(s.Name):
91+
return ErrSecretInvalidValue{Name: &s.Name}
92+
default:
93+
return nil
94+
}
95+
}
96+
97+
type FindSecretsOptions struct {
98+
db.ListOptions
99+
OwnerID int64
100+
RepoID int64
101+
}
102+
103+
func (opts *FindSecretsOptions) toConds() builder.Cond {
104+
cond := builder.NewCond()
105+
if opts.OwnerID > 0 {
106+
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
107+
}
108+
if opts.RepoID > 0 {
109+
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
110+
}
111+
112+
return cond
113+
}
114+
115+
func FindSecrets(ctx context.Context, opts FindSecretsOptions) ([]*Secret, error) {
116+
var secrets []*Secret
117+
sess := db.GetEngine(ctx)
118+
if opts.PageSize != 0 {
119+
sess = db.SetSessionPagination(sess, &opts.ListOptions)
120+
}
121+
return secrets, sess.
122+
Where(opts.toConds()).
123+
Find(&secrets)
124+
}

options/locale/locale_en-US.ini

+16
Original file line numberDiff line numberDiff line change
@@ -3212,3 +3212,19 @@ owner.settings.cleanuprules.remove.days = Remove versions older than
32123212
owner.settings.cleanuprules.remove.pattern = Remove versions matching
32133213
owner.settings.cleanuprules.success.update = Cleanup rule has been updated.
32143214
owner.settings.cleanuprules.success.delete = Cleanup rule has been deleted.
3215+
3216+
[secrets]
3217+
secrets = Secrets
3218+
description = Secrets will be passed to certain actions and cannot be read otherwise.
3219+
none = There are no secrets yet.
3220+
value = Value
3221+
name = Name
3222+
creation = Add Secret
3223+
creation.name_placeholder = case-insensitive, alphanumeric characters or underscores only, cannot start with GITEA_ or GITHUB_
3224+
creation.value_placeholder = Input any content. Whitespace at the start and end will be omitted.
3225+
creation.success = The secret '%s' has been added.
3226+
creation.failed = Failed to add secret.
3227+
deletion = Remove secret
3228+
deletion.description = Removing a secret will revoke its access to repositories. Continue?
3229+
deletion.success = The secret has been removed.
3230+
deletion.failed = Failed to remove secret.

routers/web/org/setting.go

+51
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"code.gitea.io/gitea/models"
1313
"code.gitea.io/gitea/models/db"
1414
repo_model "code.gitea.io/gitea/models/repo"
15+
secret_model "code.gitea.io/gitea/models/secret"
1516
user_model "code.gitea.io/gitea/models/user"
1617
"code.gitea.io/gitea/models/webhook"
1718
"code.gitea.io/gitea/modules/base"
@@ -37,6 +38,8 @@ const (
3738
tplSettingsHooks base.TplName = "org/settings/hooks"
3839
// tplSettingsLabels template path for render labels settings
3940
tplSettingsLabels base.TplName = "org/settings/labels"
41+
// tplSettingsSecrets template path for render secrets settings
42+
tplSettingsSecrets base.TplName = "org/settings/secrets"
4043
)
4144

4245
// Settings render the main settings page
@@ -246,3 +249,51 @@ func Labels(ctx *context.Context) {
246249
ctx.Data["LabelTemplates"] = repo_module.LabelTemplates
247250
ctx.HTML(http.StatusOK, tplSettingsLabels)
248251
}
252+
253+
// Secrets render organization secrets page
254+
func Secrets(ctx *context.Context) {
255+
ctx.Data["Title"] = ctx.Tr("repo.secrets")
256+
ctx.Data["PageIsOrgSettings"] = true
257+
ctx.Data["PageIsOrgSettingsSecrets"] = true
258+
259+
secrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{OwnerID: ctx.Org.Organization.ID})
260+
if err != nil {
261+
ctx.ServerError("FindSecrets", err)
262+
return
263+
}
264+
ctx.Data["Secrets"] = secrets
265+
266+
ctx.HTML(http.StatusOK, tplSettingsSecrets)
267+
}
268+
269+
// SecretsPost add secrets
270+
func SecretsPost(ctx *context.Context) {
271+
form := web.GetForm(ctx).(*forms.AddSecretForm)
272+
273+
_, err := secret_model.InsertEncryptedSecret(ctx, ctx.Org.Organization.ID, 0, form.Title, form.Content)
274+
if err != nil {
275+
ctx.Flash.Error(ctx.Tr("secrets.creation.failed"))
276+
log.Error("validate secret: %v", err)
277+
ctx.Redirect(ctx.Org.OrgLink + "/settings/secrets")
278+
return
279+
}
280+
281+
log.Trace("Org %d: secret added", ctx.Org.Organization.ID)
282+
ctx.Flash.Success(ctx.Tr("secrets.creation.success", form.Title))
283+
ctx.Redirect(ctx.Org.OrgLink + "/settings/secrets")
284+
}
285+
286+
// SecretsDelete delete secrets
287+
func SecretsDelete(ctx *context.Context) {
288+
id := ctx.FormInt64("id")
289+
if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id}); err != nil {
290+
ctx.Flash.Error(ctx.Tr("secrets.deletion.failed"))
291+
log.Error("delete secret %d: %v", id, err)
292+
} else {
293+
ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
294+
}
295+
296+
ctx.JSON(http.StatusOK, map[string]interface{}{
297+
"redirect": ctx.Org.OrgLink + "/settings/secrets",
298+
})
299+
}

routers/web/repo/setting.go

+40
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"code.gitea.io/gitea/models/organization"
2020
"code.gitea.io/gitea/models/perm"
2121
repo_model "code.gitea.io/gitea/models/repo"
22+
secret_model "code.gitea.io/gitea/models/secret"
2223
unit_model "code.gitea.io/gitea/models/unit"
2324
user_model "code.gitea.io/gitea/models/user"
2425
"code.gitea.io/gitea/modules/base"
@@ -1113,12 +1114,37 @@ func DeployKeys(ctx *context.Context) {
11131114
}
11141115
ctx.Data["Deploykeys"] = keys
11151116

1117+
secrets, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{RepoID: ctx.Repo.Repository.ID})
1118+
if err != nil {
1119+
ctx.ServerError("FindSecrets", err)
1120+
return
1121+
}
1122+
ctx.Data["Secrets"] = secrets
1123+
11161124
ctx.HTML(http.StatusOK, tplDeployKeys)
11171125
}
11181126

1127+
// SecretsPost response for creating a new secret
1128+
func SecretsPost(ctx *context.Context) {
1129+
form := web.GetForm(ctx).(*forms.AddSecretForm)
1130+
1131+
_, err := secret_model.InsertEncryptedSecret(ctx, 0, ctx.Repo.Repository.ID, form.Title, form.Content)
1132+
if err != nil {
1133+
ctx.Flash.Error(ctx.Tr("secrets.creation.failed"))
1134+
log.Error("validate secret: %v", err)
1135+
ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys")
1136+
return
1137+
}
1138+
1139+
log.Trace("Secret added: %d", ctx.Repo.Repository.ID)
1140+
ctx.Flash.Success(ctx.Tr("secrets.creation.success", form.Title))
1141+
ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys")
1142+
}
1143+
11191144
// DeployKeysPost response for adding a deploy key of a repository
11201145
func DeployKeysPost(ctx *context.Context) {
11211146
form := web.GetForm(ctx).(*forms.AddKeyForm)
1147+
11221148
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
11231149
ctx.Data["PageIsSettingsKeys"] = true
11241150
ctx.Data["DisableSSH"] = setting.SSH.Disabled
@@ -1177,6 +1203,20 @@ func DeployKeysPost(ctx *context.Context) {
11771203
ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys")
11781204
}
11791205

1206+
func DeleteSecret(ctx *context.Context) {
1207+
id := ctx.FormInt64("id")
1208+
if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id}); err != nil {
1209+
ctx.Flash.Error(ctx.Tr("secrets.deletion.failed"))
1210+
log.Error("delete secret %d: %v", id, err)
1211+
} else {
1212+
ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
1213+
}
1214+
1215+
ctx.JSON(http.StatusOK, map[string]interface{}{
1216+
"redirect": ctx.Repo.RepoLink + "/settings/keys",
1217+
})
1218+
}
1219+
11801220
// DeleteDeployKey response for deleting a deploy key
11811221
func DeleteDeployKey(ctx *context.Context) {
11821222
if err := asymkey_service.DeleteDeployKey(ctx.Doer, ctx.FormInt64("id")); err != nil {

0 commit comments

Comments
 (0)