Skip to content

Commit 5d0beff

Browse files
committed
Add instance-level secrets
1 parent be2a6b4 commit 5d0beff

File tree

20 files changed

+548
-89
lines changed

20 files changed

+548
-89
lines changed

custom/conf/app.example.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2836,6 +2836,8 @@ LEVEL = Info
28362836
;ABANDONED_JOB_TIMEOUT = 24h
28372837
;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow
28382838
;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip]
2839+
;; Enable/Disable global secrets
2840+
;GLOBAL_SECRETS_ENABLED = false
28392841

28402842
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
28412843
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

models/secret/secret.go

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,13 @@ import (
2525
// It can be:
2626
// 1. org/user level secret, OwnerID is org/user ID and RepoID is 0
2727
// 2. repo level secret, OwnerID is 0 and RepoID is repo ID
28+
// 3. global level secret, OwnerID is 0 and RepoID is 0
2829
//
2930
// Please note that it's not acceptable to have both OwnerID and RepoID to be non-zero,
3031
// or it will be complicated to find secrets belonging to a specific owner.
3132
// For example, conditions like `OwnerID = 1` will also return secret {OwnerID: 1, RepoID: 1},
3233
// but it's a repo level secret, not an org/user level secret.
3334
// To avoid this, make it clear with {OwnerID: 0, RepoID: 1} for repo level secrets.
34-
//
35-
// Please note that it's not acceptable to have both OwnerID and RepoID to zero, global secrets are not supported.
36-
// It's for security reasons, admin may be not aware of that the secrets could be stolen by any user when setting them as global.
3735
type Secret struct {
3836
ID int64
3937
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
@@ -69,9 +67,6 @@ func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, dat
6967
// Remove OwnerID to avoid confusion; it's not worth returning an error here.
7068
ownerID = 0
7169
}
72-
if ownerID == 0 && repoID == 0 {
73-
return nil, fmt.Errorf("%w: ownerID and repoID cannot be both zero, global secrets are not supported", util.ErrInvalidArgument)
74-
}
7570

7671
if len(data) > SecretDataMaxLength {
7772
return nil, util.NewInvalidArgumentErrorf("data too long")
@@ -108,14 +103,8 @@ type FindSecretsOptions struct {
108103

109104
func (opts FindSecretsOptions) ToConds() builder.Cond {
110105
cond := builder.NewCond()
111-
106+
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
112107
cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
113-
if opts.RepoID != 0 { // if RepoID is set
114-
// ignore OwnerID and treat it as 0
115-
cond = cond.And(builder.Eq{"owner_id": 0})
116-
} else {
117-
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
118-
}
119108

120109
if opts.SecretID != 0 {
121110
cond = cond.And(builder.Eq{"id": opts.SecretID})
@@ -164,6 +153,11 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
164153
return secrets, nil
165154
}
166155

156+
globalSecrets, err := db.Find[Secret](ctx, FindSecretsOptions{OwnerID: 0, RepoID: 0})
157+
if err != nil {
158+
log.Error("find global secrets: %v", err)
159+
return nil, err
160+
}
167161
ownerSecrets, err := db.Find[Secret](ctx, FindSecretsOptions{OwnerID: task.Job.Run.Repo.OwnerID})
168162
if err != nil {
169163
log.Error("find secrets of owner %v: %v", task.Job.Run.Repo.OwnerID, err)
@@ -175,7 +169,8 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
175169
return nil, err
176170
}
177171

178-
for _, secret := range append(ownerSecrets, repoSecrets...) {
172+
// Level precedence: Repo > Org / User > Global
173+
for _, secret := range append(globalSecrets, append(ownerSecrets, repoSecrets...)...) {
179174
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
180175
if err != nil {
181176
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err)

modules/setting/actions.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ var (
2525
EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"`
2626
AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"`
2727
SkipWorkflowStrings []string `ìni:"SKIP_WORKFLOW_STRINGS"`
28+
GlobalSecretsEnabled bool `ìni:"GLOBAL_SECRETS_ENABLED"`
2829
}{
29-
Enabled: true,
30-
DefaultActionsURL: defaultActionsURLGitHub,
31-
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
30+
Enabled: true,
31+
DefaultActionsURL: defaultActionsURLGitHub,
32+
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
33+
GlobalSecretsEnabled: false,
3234
}
3335
)
3436

options/locale/locale_en-US.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3777,6 +3777,7 @@ deletion.description = Removing a secret is permanent and cannot be undone. Cont
37773777
deletion.success = The secret has been removed.
37783778
deletion.failed = Failed to remove secret.
37793779
management = Secrets Management
3780+
instance_desc = Although secrets will be masked if users try to print them in Actions workflows, this is not absolutely secure. Users can still obtain the contents of secrets by writing malicious workflows, so please ensure that global secrets are not used by people you do not trust. Otherwise, please use organization/user-level or repository-level secrets to limit their scope of use. Alternatively, if it's acceptable to expose their contents, please use global variables.
37803781

37813782
[actions]
37823783
actions = Actions

routers/api/v1/admin/action.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@
44
package admin
55

66
import (
7+
"errors"
8+
"net/http"
9+
10+
api "code.gitea.io/gitea/modules/structs"
11+
"code.gitea.io/gitea/modules/util"
12+
"code.gitea.io/gitea/modules/web"
713
"code.gitea.io/gitea/routers/api/v1/shared"
814
"code.gitea.io/gitea/services/context"
15+
secret_service "code.gitea.io/gitea/services/secrets"
916
)
1017

1118
// ListWorkflowJobs Lists all jobs
@@ -91,3 +98,91 @@ func ListWorkflowRuns(ctx *context.APIContext) {
9198

9299
shared.ListRuns(ctx, 0, 0)
93100
}
101+
102+
// CreateOrUpdateSecret create or update one secret in instance scope
103+
func CreateOrUpdateSecret(ctx *context.APIContext) {
104+
// swagger:operation PUT /admin/actions/secrets/{secretname} admin updateAdminSecret
105+
// ---
106+
// summary: Create or Update a secret value in instance scope
107+
// consumes:
108+
// - application/json
109+
// produces:
110+
// - application/json
111+
// parameters:
112+
// - name: secretname
113+
// in: path
114+
// description: name of the secret
115+
// type: string
116+
// required: true
117+
// - name: body
118+
// in: body
119+
// schema:
120+
// "$ref": "#/definitions/CreateOrUpdateSecretOption"
121+
// responses:
122+
// "201":
123+
// description: secret created
124+
// "204":
125+
// description: secret updated
126+
// "400":
127+
// "$ref": "#/responses/error"
128+
// "404":
129+
// "$ref": "#/responses/notFound"
130+
131+
opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
132+
133+
_, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
134+
if err != nil {
135+
if errors.Is(err, util.ErrInvalidArgument) {
136+
ctx.APIError(http.StatusBadRequest, err)
137+
} else if errors.Is(err, util.ErrNotExist) {
138+
ctx.APIError(http.StatusNotFound, err)
139+
} else {
140+
ctx.APIError(http.StatusInternalServerError, err)
141+
}
142+
return
143+
}
144+
145+
if created {
146+
ctx.Status(http.StatusCreated)
147+
} else {
148+
ctx.Status(http.StatusNoContent)
149+
}
150+
}
151+
152+
// DeleteSecret delete one secret in instance scope
153+
func DeleteSecret(ctx *context.APIContext) {
154+
// swagger:operation DELETE /admin/actions/secrets/{secretname} admin deleteAdminSecret
155+
// ---
156+
// summary: Delete a secret in instance scope
157+
// consumes:
158+
// - application/json
159+
// produces:
160+
// - application/json
161+
// parameters:
162+
// - name: secretname
163+
// in: path
164+
// description: name of the secret
165+
// type: string
166+
// required: true
167+
// responses:
168+
// "204":
169+
// description: secret deleted
170+
// "400":
171+
// "$ref": "#/responses/error"
172+
// "404":
173+
// "$ref": "#/responses/notFound"
174+
175+
err := secret_service.DeleteSecretByName(ctx, 0, 0, ctx.PathParam("secretname"))
176+
if err != nil {
177+
if errors.Is(err, util.ErrInvalidArgument) {
178+
ctx.APIError(http.StatusBadRequest, err)
179+
} else if errors.Is(err, util.ErrNotExist) {
180+
ctx.APIError(http.StatusNotFound, err)
181+
} else {
182+
ctx.APIError(http.StatusInternalServerError, err)
183+
}
184+
return
185+
}
186+
187+
ctx.Status(http.StatusNoContent)
188+
}

routers/api/v1/api.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,6 @@ func Routes() *web.Router {
10551055
Post(bind(api.CreateEmailOption{}), user.AddEmail).
10561056
Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail)
10571057

1058-
// manage user-level actions features
10591058
m.Group("/actions", func() {
10601059
m.Group("/secrets", func() {
10611060
m.Combo("/{secretname}").
@@ -1710,6 +1709,11 @@ func Routes() *web.Router {
17101709
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
17111710

17121711
m.Group("/admin", func() {
1712+
m.Group("/actions/secrets", func() {
1713+
m.Combo("/{secretname}").
1714+
Put(bind(api.CreateOrUpdateSecretOption{}), admin.CreateOrUpdateSecret).
1715+
Delete(admin.DeleteSecret)
1716+
})
17131717
m.Group("/cron", func() {
17141718
m.Get("", admin.ListCronTasks)
17151719
m.Post("/{task}", admin.PostCronTask)

routers/api/v1/org/secrets.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package org
5+
6+
import (
7+
"errors"
8+
"net/http"
9+
10+
"code.gitea.io/gitea/models/db"
11+
secret_model "code.gitea.io/gitea/models/secret"
12+
api "code.gitea.io/gitea/modules/structs"
13+
"code.gitea.io/gitea/modules/util"
14+
"code.gitea.io/gitea/modules/web"
15+
"code.gitea.io/gitea/routers/api/v1/utils"
16+
"code.gitea.io/gitea/services/context"
17+
secret_service "code.gitea.io/gitea/services/secrets"
18+
)
19+
20+
// ListActionsSecrets list an organization's actions secrets
21+
func ListActionsSecrets(ctx *context.APIContext) {
22+
// swagger:operation GET /orgs/{org}/actions/secrets organization orgListActionsSecrets
23+
// ---
24+
// summary: List an organization's actions secrets
25+
// produces:
26+
// - application/json
27+
// parameters:
28+
// - name: org
29+
// in: path
30+
// description: name of the organization
31+
// type: string
32+
// required: true
33+
// - name: page
34+
// in: query
35+
// description: page number of results to return (1-based)
36+
// type: integer
37+
// - name: limit
38+
// in: query
39+
// description: page size of results
40+
// type: integer
41+
// responses:
42+
// "200":
43+
// "$ref": "#/responses/SecretList"
44+
// "404":
45+
// "$ref": "#/responses/notFound"
46+
47+
opts := &secret_model.FindSecretsOptions{
48+
OwnerID: ctx.Org.Organization.ID,
49+
ListOptions: utils.GetListOptions(ctx),
50+
}
51+
52+
secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
53+
if err != nil {
54+
ctx.APIErrorInternal(err)
55+
return
56+
}
57+
58+
apiSecrets := make([]*api.Secret, len(secrets))
59+
for k, v := range secrets {
60+
apiSecrets[k] = &api.Secret{
61+
Name: v.Name,
62+
Created: v.CreatedUnix.AsTime(),
63+
}
64+
}
65+
66+
ctx.SetTotalCountHeader(count)
67+
ctx.JSON(http.StatusOK, apiSecrets)
68+
}
69+
70+
// CreateOrUpdateSecret create or update one secret in an organization
71+
func CreateOrUpdateSecret(ctx *context.APIContext) {
72+
// swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret
73+
// ---
74+
// summary: Create or Update a secret value in an organization
75+
// consumes:
76+
// - application/json
77+
// produces:
78+
// - application/json
79+
// parameters:
80+
// - name: org
81+
// in: path
82+
// description: name of organization
83+
// type: string
84+
// required: true
85+
// - name: secretname
86+
// in: path
87+
// description: name of the secret
88+
// type: string
89+
// required: true
90+
// - name: body
91+
// in: body
92+
// schema:
93+
// "$ref": "#/definitions/CreateOrUpdateSecretOption"
94+
// responses:
95+
// "201":
96+
// description: secret created
97+
// "204":
98+
// description: secret updated
99+
// "400":
100+
// "$ref": "#/responses/error"
101+
// "404":
102+
// "$ref": "#/responses/notFound"
103+
104+
opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
105+
106+
_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
107+
if err != nil {
108+
if errors.Is(err, util.ErrInvalidArgument) {
109+
ctx.APIError(http.StatusBadRequest, err)
110+
} else if errors.Is(err, util.ErrNotExist) {
111+
ctx.APIError(http.StatusNotFound, err)
112+
} else {
113+
ctx.APIError(http.StatusInternalServerError, err)
114+
}
115+
return
116+
}
117+
118+
if created {
119+
ctx.Status(http.StatusCreated)
120+
} else {
121+
ctx.Status(http.StatusNoContent)
122+
}
123+
}
124+
125+
// DeleteSecret delete one secret in an organization
126+
func DeleteSecret(ctx *context.APIContext) {
127+
// swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret
128+
// ---
129+
// summary: Delete a secret in an organization
130+
// consumes:
131+
// - application/json
132+
// produces:
133+
// - application/json
134+
// parameters:
135+
// - name: org
136+
// in: path
137+
// description: name of organization
138+
// type: string
139+
// required: true
140+
// - name: secretname
141+
// in: path
142+
// description: name of the secret
143+
// type: string
144+
// required: true
145+
// responses:
146+
// "204":
147+
// description: secret deleted
148+
// "400":
149+
// "$ref": "#/responses/error"
150+
// "404":
151+
// "$ref": "#/responses/notFound"
152+
153+
err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"))
154+
if err != nil {
155+
if errors.Is(err, util.ErrInvalidArgument) {
156+
ctx.APIError(http.StatusBadRequest, err)
157+
} else if errors.Is(err, util.ErrNotExist) {
158+
ctx.APIError(http.StatusNotFound, err)
159+
} else {
160+
ctx.APIError(http.StatusInternalServerError, err)
161+
}
162+
return
163+
}
164+
165+
ctx.Status(http.StatusNoContent)
166+
}

0 commit comments

Comments
 (0)