All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m20s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m23s
Build and Release / Lint (push) Successful in 5m40s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h4m50s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m58s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 8m36s
Build and Release / Build Binary (linux/arm64) (push) Successful in 10m51s
Implements search by keyword (title/subtitle), tag filtering, and sort by newest/popular on explore blogs page. Adds GetExploreTopTags to show popular tags with usage counts. Enforces repository access permissions using AccessibleRepositoryCondition. Fixes secret lookup to skip scope conditions when querying by ID. Updates UI with tag cloud, search box, and sort dropdown.
285 lines
9.2 KiB
Go
285 lines
9.2 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package secret
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
actions_model "code.gitcaddy.com/server/v3/models/actions"
|
|
"code.gitcaddy.com/server/v3/models/db"
|
|
actions_module "code.gitcaddy.com/server/v3/modules/actions"
|
|
"code.gitcaddy.com/server/v3/modules/log"
|
|
secret_module "code.gitcaddy.com/server/v3/modules/secret"
|
|
"code.gitcaddy.com/server/v3/modules/setting"
|
|
"code.gitcaddy.com/server/v3/modules/timeutil"
|
|
"code.gitcaddy.com/server/v3/modules/util"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// Secret represents a secret
|
|
//
|
|
// It can be:
|
|
// 1. global/system level secret, OwnerID is 0 and RepoID is 0 (admin only)
|
|
// 2. org/user level secret, OwnerID is org/user ID and RepoID is 0
|
|
// 3. repo level secret, OwnerID is 0 and RepoID is repo ID
|
|
//
|
|
// 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.
|
|
//
|
|
// Global secrets (OwnerID=0, RepoID=0) are available to all workflows and can only be managed by admins.
|
|
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
|
|
Description string `xorm:"TEXT"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
|
}
|
|
|
|
const (
|
|
SecretDataMaxLength = 65536
|
|
SecretDescriptionMaxLength = 4096
|
|
)
|
|
|
|
// ErrSecretNotFound represents a "secret not found" error.
|
|
type ErrSecretNotFound struct {
|
|
Name string
|
|
}
|
|
|
|
func (err ErrSecretNotFound) Error() string {
|
|
return fmt.Sprintf("secret was not found [name: %s]", err.Name)
|
|
}
|
|
|
|
func (err ErrSecretNotFound) Unwrap() error {
|
|
return util.ErrNotExist
|
|
}
|
|
|
|
// InsertEncryptedSecret Creates, encrypts, and validates a new secret with yet unencrypted data and insert into database
|
|
// Note: Global secrets (ownerID=0, repoID=0) are allowed and can only be managed by admins
|
|
func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, data, description string) (*Secret, error) {
|
|
if ownerID != 0 && repoID != 0 {
|
|
// It's trying to create a secret that belongs to a repository, but OwnerID has been set accidentally.
|
|
// Remove OwnerID to avoid confusion; it's not worth returning an error here.
|
|
ownerID = 0
|
|
}
|
|
// Global secrets (ownerID=0, repoID=0) are now allowed for admin-managed system-wide secrets
|
|
|
|
if len(data) > SecretDataMaxLength {
|
|
return nil, util.NewInvalidArgumentErrorf("data too long")
|
|
}
|
|
|
|
description = util.TruncateRunes(description, SecretDescriptionMaxLength)
|
|
|
|
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
secret := &Secret{
|
|
OwnerID: ownerID,
|
|
RepoID: repoID,
|
|
Name: strings.ToUpper(name),
|
|
Data: encrypted,
|
|
Description: description,
|
|
}
|
|
return secret, db.Insert(ctx, secret)
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(Secret))
|
|
}
|
|
|
|
type FindSecretsOptions struct {
|
|
db.ListOptions
|
|
RepoID int64
|
|
OwnerID int64 // it will be ignored if RepoID is set
|
|
SecretID int64
|
|
Name string
|
|
Global bool // if true, search for global secrets (OwnerID=0, RepoID=0)
|
|
}
|
|
|
|
func (opts FindSecretsOptions) ToConds() builder.Cond {
|
|
cond := builder.NewCond()
|
|
|
|
if opts.SecretID != 0 {
|
|
// When looking up by ID, skip scope conditions
|
|
cond = cond.And(builder.Eq{"id": opts.SecretID})
|
|
return cond
|
|
}
|
|
|
|
if opts.Global {
|
|
// Global secrets have both OwnerID=0 and RepoID=0
|
|
cond = cond.And(builder.Eq{"owner_id": 0})
|
|
cond = cond.And(builder.Eq{"repo_id": 0})
|
|
} else {
|
|
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.Name != "" {
|
|
cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
|
|
}
|
|
|
|
return cond
|
|
}
|
|
|
|
// UpdateSecret changes org or user repo secret.
|
|
// If data is empty, only the description is updated.
|
|
func UpdateSecret(ctx context.Context, secretID int64, data, description string) error {
|
|
description = util.TruncateRunes(description, SecretDescriptionMaxLength)
|
|
|
|
// If data is empty, only update description
|
|
if data == "" {
|
|
s := &Secret{
|
|
Description: description,
|
|
}
|
|
affected, err := db.GetEngine(ctx).ID(secretID).Cols("description").Update(s)
|
|
if affected != 1 && err == nil {
|
|
return ErrSecretNotFound{}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Update both data and description
|
|
if len(data) > SecretDataMaxLength {
|
|
return util.NewInvalidArgumentErrorf("data too long")
|
|
}
|
|
|
|
encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s := &Secret{
|
|
Data: encrypted,
|
|
Description: description,
|
|
}
|
|
affected, err := db.GetEngine(ctx).ID(secretID).Cols("data", "description").Update(s)
|
|
if affected != 1 && err == nil {
|
|
return ErrSecretNotFound{}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[string]string, error) {
|
|
secrets := map[string]string{}
|
|
|
|
secrets["GITHUB_TOKEN"] = task.Token
|
|
secrets["GITEA_TOKEN"] = task.Token
|
|
|
|
if task.Job.Run.IsForkPullRequest && task.Job.Run.TriggerEvent != actions_module.GithubEventPullRequestTarget {
|
|
// ignore secrets for fork pull request, except GITHUB_TOKEN and GITEA_TOKEN which are automatically generated.
|
|
// for the tasks triggered by pull_request_target event, they could access the secrets because they will run in the context of the base branch
|
|
// see the documentation: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target
|
|
return secrets, nil
|
|
}
|
|
|
|
// Load secrets in order of precedence (later overrides earlier):
|
|
// 1. Global secrets (system-wide, admin-managed)
|
|
// 2. Owner/org secrets
|
|
// 3. Repository secrets (most specific, highest precedence)
|
|
globalSecrets, err := db.Find[Secret](ctx, FindSecretsOptions{Global: true})
|
|
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)
|
|
return nil, err
|
|
}
|
|
repoSecrets, err := db.Find[Secret](ctx, FindSecretsOptions{RepoID: task.Job.Run.RepoID})
|
|
if err != nil {
|
|
log.Error("find secrets of repo %v: %v", task.Job.Run.RepoID, err)
|
|
return nil, err
|
|
}
|
|
|
|
// Add secrets in order: global, then owner, then repo (repo overrides owner overrides global)
|
|
allSecrets := make([]*Secret, 0, len(globalSecrets)+len(ownerSecrets)+len(repoSecrets))
|
|
allSecrets = append(allSecrets, globalSecrets...)
|
|
allSecrets = append(allSecrets, ownerSecrets...)
|
|
allSecrets = append(allSecrets, repoSecrets...)
|
|
|
|
for _, secret := range allSecrets {
|
|
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
|
|
if err != nil {
|
|
log.Error("Unable to decrypt Actions secret %v %q, maybe SECRET_KEY is wrong: %v", secret.ID, secret.Name, err)
|
|
continue
|
|
}
|
|
secrets[secret.Name] = v
|
|
}
|
|
|
|
return secrets, nil
|
|
}
|
|
|
|
// ErrSecretConflict represents a name conflict when moving a secret.
|
|
type ErrSecretConflict struct {
|
|
Name string
|
|
}
|
|
|
|
func (err ErrSecretConflict) Error() string {
|
|
return fmt.Sprintf("a secret named '%s' already exists at the target scope", err.Name)
|
|
}
|
|
|
|
// MoveSecret moves a secret to a new scope by updating its OwnerID and RepoID.
|
|
// It checks for name conflicts at the target scope first.
|
|
func MoveSecret(ctx context.Context, secretID, newOwnerID, newRepoID int64) error {
|
|
// Load the secret
|
|
s, err := db.Find[Secret](ctx, FindSecretsOptions{SecretID: secretID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(s) == 0 {
|
|
return ErrSecretNotFound{}
|
|
}
|
|
secret := s[0]
|
|
|
|
// Check for name conflict at target scope
|
|
var conflictOpts FindSecretsOptions
|
|
if newOwnerID == 0 && newRepoID == 0 {
|
|
conflictOpts = FindSecretsOptions{Global: true, Name: secret.Name}
|
|
} else {
|
|
conflictOpts = FindSecretsOptions{OwnerID: newOwnerID, RepoID: newRepoID, Name: secret.Name}
|
|
}
|
|
existing, err := db.Find[Secret](ctx, conflictOpts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(existing) > 0 {
|
|
return ErrSecretConflict{Name: secret.Name}
|
|
}
|
|
|
|
// Update scope
|
|
secret.OwnerID = newOwnerID
|
|
secret.RepoID = newRepoID
|
|
_, err = db.GetEngine(ctx).ID(secretID).Cols("owner_id", "repo_id").Update(secret)
|
|
return err
|
|
}
|
|
|
|
func CountWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
|
|
var result int64
|
|
_, err := db.GetEngine(ctx).SQL("SELECT count(`id`) FROM `secret` WHERE `repo_id` > 0 AND `owner_id` > 0").Get(&result)
|
|
return result, err
|
|
}
|
|
|
|
func UpdateWrongRepoLevelSecrets(ctx context.Context) (int64, error) {
|
|
result, err := db.GetEngine(ctx).Exec("UPDATE `secret` SET `owner_id` = 0 WHERE `repo_id` > 0 AND `owner_id` > 0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|