Skip to content

Add instance-level secrets #27725

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2836,6 +2836,8 @@ LEVEL = Info
;ABANDONED_JOB_TIMEOUT = 24h
;; Strings committers can place inside a commit message or PR title to skip executing the corresponding actions workflow
;SKIP_WORKFLOW_STRINGS = [skip ci],[ci skip],[no ci],[skip actions],[actions skip]
;; Enable/Disable global secrets
;GLOBAL_SECRETS_ENABLED = false

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
23 changes: 9 additions & 14 deletions models/secret/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,13 @@ import (
// It can be:
// 1. org/user level secret, OwnerID is org/user ID and RepoID is 0
// 2. repo level secret, OwnerID is 0 and RepoID is repo ID
// 3. global level secret, OwnerID is 0 and RepoID is 0
//
// Please note that it's not acceptable to have both OwnerID and RepoID to be non-zero,
// or it will be complicated to find secrets belonging to a specific owner.
// For example, conditions like `OwnerID = 1` will also return secret {OwnerID: 1, RepoID: 1},
// but it's a repo level secret, not an org/user level secret.
// To avoid this, make it clear with {OwnerID: 0, RepoID: 1} for repo level secrets.
//
// Please note that it's not acceptable to have both OwnerID and RepoID to zero, global secrets are not supported.
// 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.
type Secret struct {
ID int64
OwnerID int64 `xorm:"INDEX UNIQUE(owner_repo_name) NOT NULL"`
Expand Down Expand Up @@ -69,9 +67,6 @@ func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, dat
// Remove OwnerID to avoid confusion; it's not worth returning an error here.
ownerID = 0
}
if ownerID == 0 && repoID == 0 {
return nil, fmt.Errorf("%w: ownerID and repoID cannot be both zero, global secrets are not supported", util.ErrInvalidArgument)
}

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

func (opts FindSecretsOptions) ToConds() builder.Cond {
cond := builder.NewCond()

cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can add some comments on these fields of FindSecretsOptions

cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
if opts.RepoID != 0 { // if RepoID is set
// ignore OwnerID and treat it as 0
cond = cond.And(builder.Eq{"owner_id": 0})
} else {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}

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

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

for _, secret := range append(ownerSecrets, repoSecrets...) {
// Level precedence: Repo > Org / User > Global
for _, secret := range append(globalSecrets, append(ownerSecrets, repoSecrets...)...) {
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
if err != nil {
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err)
Expand Down
8 changes: 5 additions & 3 deletions modules/setting/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ var (
EndlessTaskTimeout time.Duration `ini:"ENDLESS_TASK_TIMEOUT"`
AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"`
SkipWorkflowStrings []string `ìni:"SKIP_WORKFLOW_STRINGS"`
GlobalSecretsEnabled bool `ìni:"GLOBAL_SECRETS_ENABLED"`
}{
Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
Enabled: true,
DefaultActionsURL: defaultActionsURLGitHub,
SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"},
GlobalSecretsEnabled: false,
}
)

Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3777,6 +3777,7 @@ deletion.description = Removing a secret is permanent and cannot be undone. Cont
deletion.success = The secret has been removed.
deletion.failed = Failed to remove secret.
management = Secrets Management
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.

[actions]
actions = Actions
Expand Down
95 changes: 95 additions & 0 deletions routers/api/v1/admin/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
package admin

import (
"errors"
"net/http"

api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/shared"
"code.gitea.io/gitea/services/context"
secret_service "code.gitea.io/gitea/services/secrets"
)

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

shared.ListRuns(ctx, 0, 0)
}

// CreateOrUpdateSecret create or update one secret in instance scope
func CreateOrUpdateSecret(ctx *context.APIContext) {
// swagger:operation PUT /admin/actions/secrets/{secretname} admin updateAdminSecret
// ---
// summary: Create or Update a secret value in instance scope
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: secretname
// in: path
// description: name of the secret
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateOrUpdateSecretOption"
// responses:
// "201":
// description: secret created
// "204":
// description: secret updated
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"

opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)

_, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
} else {
ctx.APIError(http.StatusInternalServerError, err)
}
return
}

if created {
ctx.Status(http.StatusCreated)
} else {
ctx.Status(http.StatusNoContent)
}
}

// DeleteSecret delete one secret in instance scope
func DeleteSecret(ctx *context.APIContext) {
// swagger:operation DELETE /admin/actions/secrets/{secretname} admin deleteAdminSecret
// ---
// summary: Delete a secret in instance scope
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: secretname
// in: path
// description: name of the secret
// type: string
// required: true
// responses:
// "204":
// description: secret deleted
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"

err := secret_service.DeleteSecretByName(ctx, 0, 0, ctx.PathParam("secretname"))
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
} else {
ctx.APIError(http.StatusInternalServerError, err)
}
return
}

ctx.Status(http.StatusNoContent)
}
6 changes: 5 additions & 1 deletion routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,6 @@ func Routes() *web.Router {
Post(bind(api.CreateEmailOption{}), user.AddEmail).
Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail)

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

m.Group("/admin", func() {
m.Group("/actions/secrets", func() {
m.Combo("/{secretname}").
Put(bind(api.CreateOrUpdateSecretOption{}), admin.CreateOrUpdateSecret).
Delete(admin.DeleteSecret)
})
m.Group("/cron", func() {
m.Get("", admin.ListCronTasks)
m.Post("/{task}", admin.PostCronTask)
Expand Down
166 changes: 166 additions & 0 deletions routers/api/v1/org/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package org

import (
"errors"
"net/http"

"code.gitea.io/gitea/models/db"
secret_model "code.gitea.io/gitea/models/secret"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/context"
secret_service "code.gitea.io/gitea/services/secrets"
)

// ListActionsSecrets list an organization's actions secrets
func ListActionsSecrets(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/actions/secrets organization orgListActionsSecrets
// ---
// summary: List an organization's actions secrets
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/SecretList"
// "404":
// "$ref": "#/responses/notFound"

opts := &secret_model.FindSecretsOptions{
OwnerID: ctx.Org.Organization.ID,
ListOptions: utils.GetListOptions(ctx),
}

secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
}

apiSecrets := make([]*api.Secret, len(secrets))
for k, v := range secrets {
apiSecrets[k] = &api.Secret{
Name: v.Name,
Created: v.CreatedUnix.AsTime(),
}
}

ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, apiSecrets)
}

// CreateOrUpdateSecret create or update one secret in an organization
func CreateOrUpdateSecret(ctx *context.APIContext) {
// swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret
// ---
// summary: Create or Update a secret value in an organization
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of organization
// type: string
// required: true
// - name: secretname
// in: path
// description: name of the secret
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateOrUpdateSecretOption"
// responses:
// "201":
// description: secret created
// "204":
// description: secret updated
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"

opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)

_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
} else {
ctx.APIError(http.StatusInternalServerError, err)
}
return
}

if created {
ctx.Status(http.StatusCreated)
} else {
ctx.Status(http.StatusNoContent)
}
}

// DeleteSecret delete one secret in an organization
func DeleteSecret(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret
// ---
// summary: Delete a secret in an organization
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of organization
// type: string
// required: true
// - name: secretname
// in: path
// description: name of the secret
// type: string
// required: true
// responses:
// "204":
// description: secret deleted
// "400":
// "$ref": "#/responses/error"
// "404":
// "$ref": "#/responses/notFound"

err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"))
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)
} else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err)
} else {
ctx.APIError(http.StatusInternalServerError, err)
}
return
}

ctx.Status(http.StatusNoContent)
}
Loading