feat: add deploy tokens (#37306)

Deploy keys only work over SSH. A deploy token is their counterpart for HTTPS: a repository scoped credential, used as the password of a Git request, with read or read and write access. It covers Git operations and LFS, and can be regenerated in place.

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: Claude Mythos <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
ToastyTheBot
2026-08-27 03:32:44 +08:00
committed by GitHub
parent 3c4d5a6a5c
commit 646ea0f253
76 changed files with 1594 additions and 831 deletions

View File

@@ -186,23 +186,24 @@ Gitea or set your environment appropriately.`, "")
// the environment is set by serv command
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
username := os.Getenv(repo_module.EnvRepoUsername)
reponame := os.Getenv(repo_module.EnvRepoName)
ownerName := os.Getenv(repo_module.EnvRepoUsername)
repoName := os.Getenv(repo_module.EnvRepoName)
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64)
actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64)
hookOptions := private.HookOptions{
UserID: userID,
IsWiki: isWiki,
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(),
PullRequestID: prID,
DeployKeyID: deployKeyID,
ActionsTaskID: actionsTaskID,
IsWiki: isWiki,
PullRequestID: prID,
UserID: userID,
UserName: os.Getenv(repo_module.EnvPusherName),
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
}
scanner := bufio.NewScanner(os.Stdin)
@@ -257,7 +258,7 @@ Gitea or set your environment appropriately.`, "")
hookOptions.OldCommitIDs = oldCommitIDs
hookOptions.NewCommitIDs = newCommitIDs
hookOptions.RefFullNames = refFullNames
extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error)
}
@@ -283,7 +284,7 @@ Gitea or set your environment appropriately.`, "")
fmt.Fprintf(out, " Checking %d references\n", count)
extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error)
}
@@ -353,18 +354,21 @@ Gitea or set your environment appropriately.`, "")
repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName)
hookOptions := private.HookOptions{
UserName: pusherName,
UserID: pusherID,
IsWiki: isWiki,
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(),
PullRequestID: prID,
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
IsWiki: isWiki,
PullRequestID: prID,
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
UserID: pusherID,
UserName: os.Getenv(repo_module.EnvPusherName),
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
}
oldCommitIDs := make([]string, 0, hookBatchSize)
@@ -481,7 +485,6 @@ Gitea or set your environment appropriately.`, "")
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName)
// 1. Version and features negotiation.
// S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n)
@@ -553,10 +556,13 @@ Gitea or set your environment appropriately.`, "")
// S: ... ...
// S: flush-pkt
hookOptions := private.HookOptions{
UserName: pusherName,
UserID: pusherID,
IsWiki: isWiki,
GitPushOptions: make(map[string]string),
IsWiki: isWiki,
UserID: pusherID,
UserName: os.Getenv(repo_module.EnvPusherName),
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
}
hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize)
hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize)

View File

@@ -256,7 +256,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
if results.IsWiki {
return fail(ctx, "LFS Transfer is not supported for wikis", "")
}
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID})
if err != nil {
return err
}
@@ -270,7 +270,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
}
lfsTokenHref := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName))
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID})
if err != nil {
return err
}
@@ -314,15 +314,20 @@ func runServ(ctx context.Context, c *cli.Command) error {
command.Env = append(command.Env, os.Environ()...)
command.Env = append(command.Env,
repo_module.EnvRepoIsWiki+"="+strconv.FormatBool(results.IsWiki),
repo_module.EnvRepoName+"="+results.RepoName,
repo_module.EnvRepoUsername+"="+results.OwnerName,
repo_module.EnvRepoName+"="+results.RepoName,
repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
repo_module.EnvKeyID+"="+strconv.FormatInt(results.PublicKeyID, 10),
repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
repo_module.EnvPusherName+"="+results.UserName,
repo_module.EnvPusherEmail+"="+results.UserEmail,
repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
repo_module.EnvPusherExtDoerData+"="+results.UserExtDoerData,
repo_module.EnvPRID+"="+strconv.Itoa(0),
repo_module.EnvDeployKeyID+"="+strconv.FormatInt(results.DeployKeyID, 10),
repo_module.EnvKeyID+"="+strconv.FormatInt(results.KeyID, 10),
repo_module.EnvAppURL+"="+setting.AppURL,
)
// to avoid breaking, here only use the minimal environment variables for the "gitea serv" command.
@@ -334,8 +339,8 @@ func runServ(ctx context.Context, c *cli.Command) error {
}
// Update user key activity.
if results.KeyID > 0 {
if err = private.UpdatePublicKeyInRepo(ctx, results.KeyID, results.RepoID); err != nil {
if results.PublicKeyID > 0 {
if err = private.UpdatePublicKeyInRepo(ctx, results.PublicKeyID, results.RepoID); err != nil {
return fail(ctx, "Failed to update public key", "UpdatePublicKeyInRepo: %v", err)
}
}

View File

@@ -425,6 +425,7 @@ func prepareMigrationTasks() []*migration {
newMigration(349, "Expand action_schedule content column", v28.ExpandActionScheduleContent),
newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease),
newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer),
newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey),
}
return preparedMigrations
}

View File

@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v28
import (
"context"
"gitea.dev/modelmigration/base"
"xorm.io/xorm"
)
func AddTokenToDeployKey(ctx context.Context, x base.EngineMigration) error {
// Drop the old UNIQUE(s) index on (key_id, repo_id). Every token row carries key
// id 0, so the pair can no longer be unique. AddDeployKey still checks it in code.
indexes, err := x.Dialect().GetIndexes(x.DB(), ctx, "deploy_key")
if err != nil {
return err
}
if idx, ok := indexes["s"]; ok {
if _, err := x.Exec(x.Dialect().DropIndexSQL("deploy_key", idx)); err != nil {
return err
}
}
type DeployKey struct {
KeyID int64 `xorm:"INDEX"`
RepoID int64 `xorm:"INDEX"`
KeyType int `xorm:"NOT NULL DEFAULT 1"` // every existing row is an SSH key
TokenHash string `xorm:"INDEX"`
}
_, err = x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreDropIndices: true, // the bean only describes the new columns
}, new(DeployKey))
return err
}

View File

@@ -27,14 +27,7 @@ func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error
return err
}
for _, attempt := range attempts {
if attempt.TriggerUserID == user_model.ActionsUserID {
attempt.TriggerUser = user_model.NewActionsUser()
} else {
attempt.TriggerUser = users[attempt.TriggerUserID]
if attempt.TriggerUser == nil {
attempt.TriggerUser = user_model.NewGhostUser()
}
}
attempt.TriggerUser = user_model.GetPossibleUserFromMap(attempt.TriggerUserID, users)
}
return nil
}

View File

@@ -215,67 +215,6 @@ func (err ErrKeyAccessDenied) Unwrap() error {
return util.ErrPermissionDenied
}
// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
type ErrDeployKeyNotExist struct {
ID int64
KeyID int64
RepoID int64
}
// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
func IsErrDeployKeyNotExist(err error) bool {
_, ok := err.(ErrDeployKeyNotExist)
return ok
}
func (err ErrDeployKeyNotExist) Error() string {
return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
}
func (err ErrDeployKeyNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
type ErrDeployKeyAlreadyExist struct {
KeyID int64
RepoID int64
}
// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
func IsErrDeployKeyAlreadyExist(err error) bool {
_, ok := err.(ErrDeployKeyAlreadyExist)
return ok
}
func (err ErrDeployKeyAlreadyExist) Error() string {
return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
}
func (err ErrDeployKeyAlreadyExist) Unwrap() error {
return util.ErrAlreadyExist
}
// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
type ErrDeployKeyNameAlreadyUsed struct {
RepoID int64
Name string
}
// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
func IsErrDeployKeyNameAlreadyUsed(err error) bool {
_, ok := err.(ErrDeployKeyNameAlreadyUsed)
return ok
}
func (err ErrDeployKeyNameAlreadyUsed) Error() string {
return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
}
func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
return util.ErrNotExist
}
// ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error.
type ErrSSHInvalidTokenSignature struct {
Wrapped error

View File

@@ -89,6 +89,36 @@ func addPublicKey(ctx context.Context, key *PublicKey) (err error) {
return appendAuthorizedKeysToFile(key)
}
// FindOrAddDeployPublicKey returns the shared public key that deploy keys of the given content link to, adding it on first use.
func FindOrAddDeployPublicKey(ctx context.Context, content string) (*PublicKey, error) {
fingerprint, err := CalcFingerprint(content)
if err != nil {
return nil, err
}
pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
if err != nil {
return nil, err
} else if exist {
if pkey.Type != KeyTypeDeploy {
return nil, ErrKeyAlreadyExist{0, fingerprint, ""}
}
return pkey, nil
}
pkey = &PublicKey{
Mode: perm.AccessModeNone,
Type: KeyTypeDeploy,
Name: "(DeployKey)",
Content: content,
Fingerprint: fingerprint,
}
if err = addPublicKey(ctx, pkey); err != nil {
return nil, fmt.Errorf("addPublicKey: %w", err)
}
return pkey, nil
}
// AddPublicKey adds new public key to database and authorized_keys file.
func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) {
log.Trace(content)

View File

@@ -1,175 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package asymkey
import (
"context"
"fmt"
"time"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// DeployKey represents deploy key information and its relation with repository.
type DeployKey struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"UNIQUE(s) INDEX"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Name string
Fingerprint string
Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
PublicKey *PublicKey `xorm:"-"`
}
func (key *DeployKey) HasUsed() bool {
return key.UpdatedUnix > key.CreatedUnix
}
func (key *DeployKey) HasRecentActivity() bool {
return key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
}
func (key *DeployKey) LoadPublicKey(ctx context.Context) (err error) {
if key.PublicKey != nil {
return nil
}
key.PublicKey, err = GetPublicKeyByID(ctx, key.KeyID)
return err
}
// IsReadOnly checks if the key can only be used for read operations, used by template
func (key *DeployKey) IsReadOnly() bool {
return key.Mode == perm.AccessModeRead
}
func init() {
db.RegisterModel(new(DeployKey))
}
func checkDeployKey(ctx context.Context, repoID, publicKeyID int64, name string) error {
// Note: We want error detail, not just true or false here.
has, err := db.GetEngine(ctx).
Where("repo_id=? AND (key_id=? OR name=?)", repoID, publicKeyID, name).
Get(new(DeployKey))
if err != nil {
return err
} else if has {
return ErrDeployKeyAlreadyExist{publicKeyID, repoID}
}
return nil
}
// addDeployKey adds new key-repo relation.
func addDeployKey(ctx context.Context, repoID, publicKeyID int64, name, fingerprint string, mode perm.AccessMode) (*DeployKey, error) {
if err := checkDeployKey(ctx, repoID, publicKeyID, name); err != nil {
return nil, err
}
key := &DeployKey{KeyID: publicKeyID, RepoID: repoID, Name: name, Fingerprint: fingerprint, Mode: mode}
return key, db.Insert(ctx, key)
}
// AddDeployKey add new deploy key to database and authorized_keys file.
func AddDeployKey(ctx context.Context, repoID int64, name, content string, accessMode perm.AccessMode) (*DeployKey, error) {
fingerprint, err := CalcFingerprint(content)
if err != nil {
return nil, err
}
if accessMode != perm.AccessModeRead && accessMode != perm.AccessModeWrite {
return nil, util.NewInvalidArgumentErrorf("invalid access mode")
}
return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
if err != nil {
return nil, err
} else if exist {
if pkey.Type != KeyTypeDeploy {
return nil, ErrKeyAlreadyExist{0, fingerprint, ""}
}
} else {
// First time use this deploy key, add a shared public key
pkey = &PublicKey{
Mode: perm.AccessModeNone,
Type: KeyTypeDeploy,
Name: "(DeployKey)",
Content: content,
Fingerprint: fingerprint,
}
if err = addPublicKey(ctx, pkey); err != nil {
return nil, fmt.Errorf("addPublicKey: %w", err)
}
}
return addDeployKey(ctx, repoID, pkey.ID, name, fingerprint, accessMode)
})
}
// GetDeployKeyByID returns deploy key by given ID.
func GetDeployKeyByID(ctx context.Context, repoID, deployKeyID int64) (*DeployKey, error) {
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"id": deployKeyID, "repo_id": repoID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{deployKeyID, 0, repoID}
}
return key, nil
}
// GetDeployKeyByRepoPublicKey returns deploy key by given public key ID and repository ID.
func GetDeployKeyByRepoPublicKey(ctx context.Context, repoID, publicKeyID int64) (*DeployKey, error) {
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": publicKeyID, "repo_id": repoID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{0, publicKeyID, repoID}
}
return key, nil
}
// IsDeployKeyExistByPublicKeyID return true if there is at least one deploy-key with the key id
func IsDeployKeyExistByPublicKeyID(ctx context.Context, keyID int64) (bool, error) {
return db.GetEngine(ctx).
Where("key_id = ?", keyID).
Get(new(DeployKey))
}
// UpdateDeployKeyCols updates deploy key information in the specified columns.
func UpdateDeployKeyCols(ctx context.Context, key *DeployKey, cols ...string) error {
_, err := db.GetEngine(ctx).ID(key.ID).Cols(cols...).Update(key)
return err
}
// ListDeployKeysOptions are options for ListDeployKeys
type ListDeployKeysOptions struct {
db.ListOptions
RepoID int64
KeyID int64
Fingerprint string
}
func (opt ListDeployKeysOptions) ToOrders() string {
return "name"
}
func (opt ListDeployKeysOptions) ToConds() builder.Cond {
cond := builder.NewCond()
cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) // repo ID must be used
if opt.KeyID != 0 {
cond = cond.And(builder.Eq{"key_id": opt.KeyID})
}
if opt.Fingerprint != "" {
cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint})
}
return cond
}

View File

@@ -0,0 +1,99 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"context"
"time"
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/timeutil"
"xorm.io/builder"
)
type KeyType int // SSH public key or HTTP auth token
const (
KeyTypeSSH KeyType = iota + 1
KeyTypeToken
)
type DeployKey struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX"`
RepoID int64 `xorm:"INDEX"`
Name string
KeyType KeyType `xorm:"NOT NULL DEFAULT 1"`
Fingerprint string
PublicKey *asymkey.PublicKey `xorm:"-"`
TokenHash string `xorm:"INDEX"` // sha256 of the token, which carries enough entropy to need no salt
Token string `xorm:"-"` // only set when the token is created
Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// these methods below are mainly used by templates
func (key *DeployKey) HasRecentActivity() bool {
return key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
}
func (key *DeployKey) HasUsed() bool { return key.UpdatedUnix > key.CreatedUnix }
func (key *DeployKey) IsReadOnly() bool { return key.Mode == perm.AccessModeRead }
func (key *DeployKey) IsKeyTypeToken() bool { return key.KeyType == KeyTypeToken }
func init() {
db.RegisterModel(new(DeployKey))
}
func checkDeployKeyName(ctx context.Context, repoID int64, name string) error {
has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "name": name})
if err != nil {
return err
} else if has {
return ErrDeployKeyNameAlreadyUsed{repoID, name}
}
return nil
}
// UpdateDeployKeyLastUsed marks the key as used now.
func UpdateDeployKeyLastUsed(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Cols("updated_unix").Update(&DeployKey{UpdatedUnix: timeutil.TimeStampNow()})
return err
}
// ListDeployKeysOptions are options for ListDeployKeys
type ListDeployKeysOptions struct {
db.ListOptions
RepoID int64
KeyID int64
Fingerprint string
}
func (opt ListDeployKeysOptions) ToOrders() string {
return "name"
}
func (opt ListDeployKeysOptions) ToConds() builder.Cond {
cond := builder.NewCond()
cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) // repo ID must be used
if opt.KeyID != 0 {
cond = cond.And(builder.Eq{"key_id": opt.KeyID})
}
if opt.Fingerprint != "" {
cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint})
}
return cond
}

View File

@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"context"
"gitea.dev/models/asymkey"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/util"
"xorm.io/builder"
)
// AddDeployKeySSH add new deploy-key to database and authorized_keys file.
func AddDeployKeySSH(ctx context.Context, repoID int64, name, content string, accessMode perm.AccessMode) (*DeployKey, error) {
if accessMode != perm.AccessModeRead && accessMode != perm.AccessModeWrite {
return nil, util.NewInvalidArgumentErrorf("invalid access mode")
}
return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
pkey, err := asymkey.FindOrAddDeployPublicKey(ctx, content)
if err != nil {
return nil, err
}
if has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "key_id": pkey.ID}); err != nil {
return nil, err
} else if has {
return nil, ErrDeployKeyAlreadyExist{pkey.ID, repoID}
}
if err := checkDeployKeyName(ctx, repoID, name); err != nil {
return nil, err
}
key := &DeployKey{KeyID: pkey.ID, RepoID: repoID, KeyType: KeyTypeSSH, Name: name, Fingerprint: pkey.Fingerprint, Mode: accessMode}
return key, db.Insert(ctx, key)
})
}
func (key *DeployKey) LoadPublicKey(ctx context.Context) (err error) {
if key.PublicKey != nil {
return nil
}
key.PublicKey, err = asymkey.GetPublicKeyByID(ctx, key.KeyID)
return err
}
// GetDeployKeyByID returns deploy-key by given ID.
func GetDeployKeyByID(ctx context.Context, repoID, deployKeyID int64) (*DeployKey, error) {
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"id": deployKeyID, "repo_id": repoID})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{deployKeyID, 0, repoID}
}
return key, nil
}
// GetDeployKeyByRepoPublicKey returns deploy-key by given public key ID and repository ID.
func GetDeployKeyByRepoPublicKey(ctx context.Context, repoID, publicKeyID int64) (*DeployKey, error) {
// the type is part of the condition because every token row carries key id 0
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": publicKeyID, "repo_id": repoID, "key_type": KeyTypeSSH})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{0, publicKeyID, repoID}
}
return key, nil
}
// IsDeployKeyExistByPublicKeyID return true if there is at least one deploy-key with the key id
func IsDeployKeyExistByPublicKeyID(ctx context.Context, keyID int64) (bool, error) {
return db.Exist[DeployKey](ctx, builder.Eq{"key_id": keyID, "key_type": KeyTypeSSH})
}

View File

@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"context"
"strings"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/modules/base"
"gitea.dev/modules/util"
"xorm.io/builder"
)
const (
DeployTokenPrefix = "gdt_" // lets a secret scanner recognize a leaked token
deployTokenLength = 43 // 256 bits of entropy over the 62 alphanumerical characters
)
func (key *DeployKey) generateToken() {
key.Token = DeployTokenPrefix + util.CryptoRandomString(deployTokenLength)
key.TokenHash = base.EncodeSha256(key.Token)
key.Fingerprint = key.Token[:len(DeployTokenPrefix)+2] + "********" + key.Token[len(key.Token)-2:]
}
// AddDeployKeyToken adds a token that authenticates git HTTP requests for one repository.
// The plaintext token is only readable on the returned key.
func AddDeployKeyToken(ctx context.Context, repoID int64, name string, accessMode perm.AccessMode) (*DeployKey, error) {
key := &DeployKey{
RepoID: repoID,
KeyType: KeyTypeToken,
Name: name,
Mode: accessMode,
}
key.generateToken()
return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
if err := checkDeployKeyName(ctx, repoID, name); err != nil {
return nil, err
}
return key, db.Insert(ctx, key)
})
}
// RegenerateDeployKeyToken replaces the token value of an existing deploy token, keeping its name and access mode.
func RegenerateDeployKeyToken(ctx context.Context, repoID, keyID int64) (*DeployKey, error) {
key, err := GetDeployKeyByID(ctx, repoID, keyID)
if err != nil {
return nil, err
}
if key.KeyType != KeyTypeToken {
return nil, ErrDeployKeyNotExist{keyID, 0, repoID}
}
key.generateToken()
_, err = db.GetEngine(ctx).ID(key.ID).Cols("token_hash", "fingerprint").NoAutoTime().Update(key)
return key, err
}
// VerifyDeployKeyToken returns the deploy-key which the given plaintext token authenticates.
func VerifyDeployKeyToken(ctx context.Context, token string) (*DeployKey, error) {
if !strings.HasPrefix(token, DeployTokenPrefix) { // spares a query for every password of a normal user
return nil, ErrDeployKeyNotExist{}
}
key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"token_hash": base.EncodeSha256(token), "key_type": KeyTypeToken})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrDeployKeyNotExist{}
}
return key, nil
}

View File

@@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"testing"
"gitea.dev/models/db"
"gitea.dev/models/perm"
"gitea.dev/models/unittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeployToken(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
require.NoError(t, err)
assert.False(t, key.IsReadOnly())
assert.Len(t, key.Token, len(DeployTokenPrefix)+deployTokenLength)
got, err := VerifyDeployKeyToken(t.Context(), key.Token)
require.NoError(t, err)
assert.Equal(t, key.ID, got.ID)
assert.Empty(t, got.Token, "the token itself is never stored")
_, err = VerifyDeployKeyToken(t.Context(), "not-a-token")
assert.True(t, IsErrDeployKeyNotExist(err))
_, err = AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
assert.True(t, IsErrDeployKeyNameAlreadyUsed(err))
regenerated, err := RegenerateDeployKeyToken(t.Context(), 1, key.ID)
require.NoError(t, err)
assert.Equal(t, key.Name, regenerated.Name)
assert.Equal(t, key.Mode, regenerated.Mode)
assert.False(t, unittest.AssertExistsAndLoadBean(t, &DeployKey{ID: key.ID}).HasUsed(), "regenerating is not a use")
_, err = VerifyDeployKeyToken(t.Context(), key.Token)
assert.True(t, IsErrDeployKeyNotExist(err), "the old token stops working")
_, err = VerifyDeployKeyToken(t.Context(), regenerated.Token)
require.NoError(t, err)
// an SSH deploy-key has no token to regenerate
sshKey := &DeployKey{RepoID: 1, KeyType: KeyTypeSSH, Name: "ssh"}
require.NoError(t, db.Insert(t.Context(), sshKey))
_, err = RegenerateDeployKeyToken(t.Context(), 1, sshKey.ID)
assert.True(t, IsErrDeployKeyNotExist(err))
}

71
models/deploykey/error.go Normal file
View File

@@ -0,0 +1,71 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"fmt"
"gitea.dev/modules/util"
)
// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
type ErrDeployKeyNotExist struct {
ID int64
KeyID int64
RepoID int64
}
// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
func IsErrDeployKeyNotExist(err error) bool {
_, ok := err.(ErrDeployKeyNotExist)
return ok
}
func (err ErrDeployKeyNotExist) Error() string {
return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
}
func (err ErrDeployKeyNotExist) Unwrap() error {
return util.ErrNotExist
}
// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
type ErrDeployKeyAlreadyExist struct {
KeyID int64
RepoID int64
}
// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
func IsErrDeployKeyAlreadyExist(err error) bool {
_, ok := err.(ErrDeployKeyAlreadyExist)
return ok
}
func (err ErrDeployKeyAlreadyExist) Error() string {
return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
}
func (err ErrDeployKeyAlreadyExist) Unwrap() error {
return util.ErrAlreadyExist
}
// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
type ErrDeployKeyNameAlreadyUsed struct {
RepoID int64
Name string
}
// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
func IsErrDeployKeyNameAlreadyUsed(err error) bool {
_, ok := err.(ErrDeployKeyNameAlreadyUsed)
return ok
}
func (err ErrDeployKeyNameAlreadyUsed) Error() string {
return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
}
func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
return util.ErrNotExist
}

View File

@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykey
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m, &unittest.TestOptions{FixtureFiles: []string{}}) // the tests insert what they assert on
}

View File

@@ -748,4 +748,18 @@
config: "{}"
created_unix: 946684810
-
id: 113
repo_id: 19
type: 1
config: "{}"
created_unix: 946684810
-
id: 114
repo_id: 20
type: 1
config: "{}"
created_unix: 946684810
# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly

View File

@@ -13,6 +13,7 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/organization"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
@@ -383,12 +384,39 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
return perm, nil
}
// getDeployKeyRepoPermission returns the permissions that a deploy key grants on a repository.
// A key only ever reaches the git data of the one repository it was added to, at its own access mode.
func getDeployKeyRepoPermission(ctx context.Context, repo *repo_model.Repository, keyID int64) (perm Permission, err error) {
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, keyID)
if err != nil {
if deploykey_model.IsErrDeployKeyNotExist(err) {
return perm, nil // the key belongs to another repository, so it grants nothing here
}
return perm, err
}
if err = repo.LoadUnits(ctx); err != nil {
return perm, err
}
perm.units = repo.Units
perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
for _, u := range repo.Units {
if u.Type == unit.TypeCode || u.Type == unit.TypeWiki { // a deploy-key only ever reaches git data
perm.unitsMode[u.Type] = key.Mode
}
}
return perm, nil
}
// GetDoerRepoPermission returns the repository permission for the current actor,
// dispatching to GetActionsUserRepoPermission when the actor is an Actions token user.
// dispatching to the credential-scoped permissions when the actor is a token or key user.
func GetDoerRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (Permission, error) {
if taskID, ok := user_model.GetActionsUserTaskID(user); ok {
return GetActionsUserRepoPermission(ctx, repo, user, taskID)
}
if keyID, ok := user_model.GetDeployKeyUserDeployKeyID(user); ok {
return getDeployKeyRepoPermission(ctx, repo, keyID)
}
return GetIndividualUserRepoPermission(ctx, repo, user)
}

View File

@@ -47,9 +47,7 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size
func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
// ghost user was deleted, Gitea actions is a bot user, 0 means the user should be a virtual user
// which comes from git configure information
if u.IsGhost() || u.IsGiteaActions() || u.ID <= 0 {
if u.ID <= 0 {
return avatars.DefaultAvatarLink()
}

View File

@@ -159,6 +159,11 @@ type User struct {
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"`
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
// When the user model is used as a doer (all existing code does so), the doer can have extra details.
// * Actions task doer needs to bind to the task
// * Deploy-key doer needs to bind to the key
ExtDoerData ExtDoerData `xorm:"-"`
}
// Meta defines the meta information of a user, to be stored in the K/V table
@@ -418,9 +423,9 @@ func (u *User) IsOrganization() bool {
return u.Type == UserTypeOrganization
}
// IsIndividual returns true if user is actually a individual user.
// IsIndividual returns true if user is actually an individual user.
func (u *User) IsIndividual() bool {
return u.Type == UserTypeIndividual
return u.ID > 0 && u.Type == UserTypeIndividual
}
// IsTypeBot returns whether the user is of type bot
@@ -513,9 +518,8 @@ func (u *User) GitName() string {
}
// IsMailable checks if a user is eligible to receive emails.
// System users like Ghost and Gitea Actions are excluded.
func (u *User) IsMailable() bool {
return u.IsActive && !u.IsGiteaActions() && !u.IsGhost()
return u.ID > 0 && u.IsActive && u.IsIndividual()
}
// IsUserExist checks if given username exist,
@@ -551,10 +555,11 @@ type globalVarsStruct struct {
emailToReplacer *strings.Replacer
emailRegexp *regexp.Regexp
systemUserNewFuncs map[int64]func() *User
systemUserNameIdMap map[string]int64
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
return &globalVarsStruct{
ret := &globalVarsStruct{
// Note: The set of characters here can safely expand without a breaking change,
// but characters removed from this set can cause user account linking to break
customCharsReplacement: strings.NewReplacer("Æ", "AE"),
@@ -573,12 +578,17 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
";", "",
),
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
systemUserNewFuncs: map[int64]func() *User{
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
},
}
userFuncs := []func() *User{NewGhostUser, NewActionsUser, NewDeployKeyUser}
ret.systemUserNewFuncs = map[int64]func() *User{}
ret.systemUserNameIdMap = map[string]int64{}
for _, fn := range userFuncs {
u := fn()
ret.systemUserNewFuncs[u.ID] = fn
ret.systemUserNameIdMap[u.LowerName] = u.ID
}
return ret
})
// NormalizeUserName only takes the name part if it is an email address, transforms it diacritics to ASCII characters.
@@ -1023,7 +1033,7 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err
}
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
if id < 0 {
if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok {

View File

@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"strconv"
"strings"
)
type ExtDoerData interface {
EncodeToString() string
DecodeFromString(string) error
}
type extDoerGiteaActions struct {
TaskID int64
}
var _ ExtDoerData = (*extDoerGiteaActions)(nil)
func (e *extDoerGiteaActions) EncodeToString() string {
return "gitea-actions:" + strconv.FormatInt(e.TaskID, 10)
}
func (e *extDoerGiteaActions) DecodeFromString(s string) (err error) {
idStr, _ := strings.CutPrefix(s, "gitea-actions:")
e.TaskID, err = strconv.ParseInt(idStr, 10, 64)
return err
}
type extDoerDeployKey struct {
DeployKeyID int64
}
var _ ExtDoerData = (*extDoerDeployKey)(nil)
func (e *extDoerDeployKey) EncodeToString() string {
return "deploy-key:" + strconv.FormatInt(e.DeployKeyID, 10)
}
func (e *extDoerDeployKey) DecodeFromString(s string) (err error) {
idStr, _ := strings.CutPrefix(s, "deploy-key:")
e.DeployKeyID, err = strconv.ParseInt(idStr, 10, 64)
return err
}

View File

@@ -31,18 +31,15 @@ func GetUsersMapByIDs(ctx context.Context, userIDs []int64) (map[int64]*User, er
}
func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
switch userID {
case GhostUserID:
return NewGhostUser()
case ActionsUserID:
return NewActionsUser()
case 0:
if userID == 0 {
return nil
default:
user, ok := usererMaps[userID]
if !ok {
return NewGhostUser()
}
return user
}
if newFunc, ok := globalVars().systemUserNewFuncs[userID]; ok {
return newFunc()
}
user, ok := usererMaps[userID]
if !ok {
return NewGhostUser()
}
return user
}

View File

@@ -4,7 +4,7 @@
package user
import (
"strconv"
"context"
"strings"
"gitea.dev/modules/structs"
@@ -32,59 +32,86 @@ func (u *User) IsGhost() bool {
return u.ID == GhostUserID && u.Name == GhostUserName
}
// newSystemUser creates and returns a fake user for system use.
// The builtin username can be wrapped in parentheses to avoid conflicts with real usernames.
func newSystemUser(id int64, name, fullName string) *User {
return &User{
ID: id,
Name: name,
LowerName: strings.ToLower(name),
IsActive: true,
FullName: fullName,
Type: UserTypeBot,
Visibility: structs.VisibleTypePublic,
}
}
const (
ActionsUserID int64 = -2
ActionsUserName = "gitea-actions"
ActionsUserEmail = "teabot@gitea.io"
ActionsUserID int64 = -2
DeployKeyUserID int64 = -3
)
// NewActionsUser creates and returns a fake user for running the actions.
func NewActionsUser() *User {
return &User{
ID: ActionsUserID,
Name: ActionsUserName,
LowerName: ActionsUserName,
IsActive: true,
FullName: "Gitea Actions",
Email: ActionsUserEmail,
KeepEmailPrivate: true,
LoginName: ActionsUserName,
Type: UserTypeBot,
Visibility: structs.VisibleTypePublic,
return newSystemUser(ActionsUserID, "gitea-actions", "Gitea Actions")
}
func GetActionsUserTaskID(u *User) (int64, bool) {
if u == nil || u.ExtDoerData == nil || u.ID != ActionsUserID {
return 0, false
}
extData := u.ExtDoerData.(*extDoerGiteaActions) //nolint:forcetypeassert // must be valid
return extData.TaskID, true
}
func NewActionsUserWithTaskID(id int64) *User {
u := NewActionsUser()
// LoginName is for only internal usage in this case, so it can be moved to other fields in the future
u.LoginSource = -1
u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10)
u.ExtDoerData = &extDoerGiteaActions{TaskID: id}
return u
}
func GetActionsUserTaskID(u *User) (int64, bool) {
if u == nil || u.ID != ActionsUserID {
return 0, false
}
prefix, payload, _ := strings.Cut(u.LoginName, "/")
if prefix != "@"+ActionsUserName {
return 0, false
} else if taskID, err := strconv.ParseInt(payload, 10, 64); err == nil {
return taskID, true
}
return 0, false
func NewDeployKeyUser() *User {
return newSystemUser(DeployKeyUserID, "(deploy-key)", "Deploy Key")
}
func (u *User) IsGiteaActions() bool {
return u != nil && u.ID == ActionsUserID
func GetDeployKeyUserDeployKeyID(u *User) (int64, bool) {
// ok, the function name seems wordy, it is intentionally to distinguish from other "keys" like "public key id"
// it was a mess in the "pre-receive" hook code
if u == nil || u.ExtDoerData == nil || u.ID != DeployKeyUserID {
return 0, false
}
extData := u.ExtDoerData.(*extDoerDeployKey) //nolint:forcetypeassert // must be valid
return extData.DeployKeyID, true
}
func NewDeployKeyUserWithKeyID(id int64) *User {
u := NewDeployKeyUser()
u.ExtDoerData = &extDoerDeployKey{DeployKeyID: id}
return u
}
func GetSystemUserByName(name string) *User {
if strings.EqualFold(name, GhostUserName) {
return NewGhostUser()
}
if strings.EqualFold(name, ActionsUserName) {
return NewActionsUser()
lowerName := strings.ToLower(name)
uid := globalVars().systemUserNameIdMap[lowerName]
if fn := globalVars().systemUserNewFuncs[uid]; fn != nil {
return fn()
}
return nil
}
func GetDoerUser(ctx context.Context, id int64, extDoerData string) (u *User, _ error) {
if id > 0 {
return GetUserByID(ctx, id)
}
switch id {
case ActionsUserID:
u = NewActionsUser()
u.ExtDoerData = &extDoerGiteaActions{}
case DeployKeyUserID:
u = NewDeployKeyUser()
u.ExtDoerData = &extDoerDeployKey{}
default:
return nil, ErrUserNotExist{UID: id}
}
return u, u.ExtDoerData.DecodeFromString(extDoerData)
}

View File

@@ -27,7 +27,6 @@ func TestSystemUser(t *testing.T) {
assert.Equal(t, int64(-2), uid)
assert.Equal(t, "gitea-actions", u.Name)
assert.Equal(t, "gitea-actions", u.LowerName)
assert.True(t, u.IsGiteaActions())
u = GetSystemUserByName("Gitea-actionS")
require.NotNil(t, u)

View File

@@ -24,20 +24,23 @@ const (
// HookOptions represents the options for the Hook calls
type HookOptions struct {
OldCommitIDs []string
NewCommitIDs []string
RefFullNames []git.RefName
UserID int64
UserName string
IsWiki bool
OldCommitIDs []string
NewCommitIDs []string
RefFullNames []git.RefName
GitObjectDirectory string
GitAlternativeObjectDirectories string
GitQuarantinePath string
GitPushOptions GitPushOptions
PullRequestID int64
PushTrigger repository.PushTrigger
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
IsWiki bool
ActionsTaskID int64 // if the pusher is an Actions user, the task ID
PullRequestID int64
PushTrigger repository.PushTrigger
UserID int64
UserName string
UserExtDoerData string
}
// SSHLogOption ssh log options

View File

@@ -33,16 +33,18 @@ func ServNoCommand(ctx context.Context, keyID int64) (*asymkey_model.PublicKey,
// ServCommandResults are the results of a call to the private route serv
type ServCommandResults struct {
IsWiki bool
DeployKeyID int64
KeyID int64 // public key
KeyName string // this field is ambiguous, it can be the name of DeployKey, or the name of the PublicKey
UserName string
UserEmail string
UserID int64
OwnerName string
RepoName string
RepoID int64
IsWiki bool
OwnerName string
RepoName string
RepoID int64
PublicKeyID int64
UserName string
UserEmail string
UserID int64
UserExtDoerData string
RepoStoragePath string
}

View File

@@ -16,21 +16,23 @@ import (
// env keys for git hooks need
const (
EnvRepoName = "GITEA_REPO_NAME"
EnvRepoUsername = "GITEA_REPO_USER_NAME"
EnvRepoID = "GITEA_REPO_ID"
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
EnvPusherName = "GITEA_PUSHER_NAME"
EnvPusherEmail = "GITEA_PUSHER_EMAIL"
EnvPusherID = "GITEA_PUSHER_ID"
EnvKeyID = "GITEA_KEY_ID" // public key ID
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
EnvPRID = "GITEA_PR_ID"
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
EnvIsInternal = "GITEA_INTERNAL_PUSH"
EnvAppURL = "GITEA_ROOT_URL"
EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID"
EnvRepoName = "GITEA_REPO_NAME"
EnvRepoUsername = "GITEA_REPO_USER_NAME" // owner name
EnvRepoID = "GITEA_REPO_ID"
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
EnvKeyID = "GITEA_KEY_ID" // public key ID
EnvPusherName = "GITEA_PUSHER_NAME"
EnvPusherEmail = "GITEA_PUSHER_EMAIL"
EnvPusherID = "GITEA_PUSHER_ID"
EnvPusherExtDoerData = "GITEA_PUSHER_EXT_DOER_DATA"
EnvPRID = "GITEA_PR_ID"
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
EnvIsInternal = "GITEA_INTERNAL_PUSH"
EnvAppURL = "GITEA_ROOT_URL"
)
type PushTrigger string
@@ -68,8 +70,8 @@ func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository,
if !doer.KeepEmailPrivate {
env = append(env, EnvPusherEmail+"="+doer.Email)
}
if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser {
env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10))
if doer.ExtDoerData != nil {
env = append(env, EnvPusherExtDoerData+"="+doer.ExtDoerData.EncodeToString())
}
return env
}

View File

@@ -146,8 +146,15 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co
}
}
// NewRequestContextForTest creates a new RequestContext for testing purposes
// It doesn't add the context to the process manager, nor do cleanup
func NewRequestContextForTest(parentCtx context.Context) RequestContext {
return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}}
type TestingT interface {
Cleanup(func())
Context() context.Context
}
// NewRequestContextForTest creates a new RequestContext for testing purposes
func NewRequestContextForTest(t TestingT) RequestContext {
store := &requestDataStore{values: make(map[any]any)}
ret := &requestContext{Context: t.Context(), RequestDataStore: store}
t.Cleanup(store.cleanUp)
return ret
}

View File

@@ -7,30 +7,33 @@ import (
"time"
)
// DeployKey a deploy key
type DeployKey struct {
// ID is the unique identifier for the deploy key
// ID is the unique identifier for the deploy-key
ID int64 `json:"id"`
// Type tells whether the key authenticates over SSH or with a token over HTTPS
// enum: ssh,token
KeyType string `json:"key_type"`
// KeyID is the associated public key ID
KeyID int64 `json:"key_id"`
// Key contains the actual SSH key content
Key string `json:"key"`
// URL is the API URL for this deploy key
// URL is the API URL for this deploy-key
URL string `json:"url"`
// Title is the human-readable name for the key
Title string `json:"title"`
// Fingerprint is the key's fingerprint
Fingerprint string `json:"fingerprint"`
// Token is the plaintext token of an HTTPS key, only returned when it is created
Token string `json:"token,omitempty"`
// swagger:strfmt date-time
// Created is the time when the deploy key was added
// Created is the time when the deploy-key was added
Created time.Time `json:"created_at"`
// ReadOnly indicates if the key has read-only access
ReadOnly bool `json:"read_only"`
// Repository is the repository this deploy key belongs to
// Repository is the repository this deploy-key belongs to
Repository *Repository `json:"repository,omitempty"`
}
// CreateKeyOption options when creating a key
type CreateKeyOption struct {
// Title of the key to add
//
@@ -43,7 +46,15 @@ type CreateKeyOption struct {
// unique: true
Key string `json:"key" binding:"Required"`
// Describe if the key has only read access or read/write
//
// required: false
ReadOnly bool `json:"read_only"`
}
type CreateDeployKeyTokenOption struct {
// Title of the token to add
//
// required: true
// unique: true
Title string `json:"title" binding:"Required;MaxSize(50)"`
// Describe if the token has only read access or read/write
ReadOnly bool `json:"read_only"`
}

View File

@@ -15,7 +15,7 @@ import (
)
func TestRenderTimelineEventComment(t *testing.T) {
ctx := reqctx.NewRequestContextForTest(t.Context())
ctx := reqctx.NewRequestContextForTest(t)
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
ut := &RenderUtils{ctx: ctx}
var createdStr template.HTML = "(created-at)"

View File

@@ -62,7 +62,7 @@ func TestMain(m *testing.M) {
}
func newTestRenderUtils(t *testing.T) *RenderUtils {
ctx := reqctx.NewRequestContextForTest(t.Context())
ctx := reqctx.NewRequestContextForTest(t)
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
return NewRenderUtils(ctx)
}

View File

@@ -2387,19 +2387,22 @@
"repo.settings.packagist_api_token": "API token",
"repo.settings.packagist_package_url": "Packagist package URL",
"repo.settings.deploy_keys": "Deploy Keys",
"repo.settings.add_deploy_key": "Add Deploy Key",
"repo.settings.deploy_key_desc": "Deploy keys have read-only pull access to the repository.",
"repo.settings.is_writable": "Enable Write Access",
"repo.settings.is_writable_info": "Allow this deploy key to <strong>push</strong> to the repository.",
"repo.settings.no_deploy_keys": "There are no deploy keys yet.",
"repo.settings.title": "Title",
"repo.settings.deploy_key_content": "Content",
"repo.settings.add_deploy_key_ssh": "Add SSH key",
"repo.settings.deploy_key_ssh_desc": "Add an SSH public key as deploy key, then use its private key to access the repository.",
"repo.settings.deploy_key_token_desc": "An HTTP token is used as the password of a Git request over HTTPS for this repository.",
"repo.settings.generate_deploy_token": "Generate HTTP Token",
"repo.settings.generate_deploy_token_success": "HTTP token %s generated. Copy it now, it is not shown again.",
"repo.settings.regenerate_deploy_token_desc": "Regenerating an HTTP token will revoke the current one. Continue?",
"repo.settings.regenerate_deploy_token_success": "HTTP token %s regenerated. Copy it now, it is not shown again.",
"repo.settings.add_key_success": "Deploy key %s has been added.",
"repo.settings.key_been_used": "A deploy key with identical content is already in use.",
"repo.settings.key_name_used": "A deploy key with the same name already exists.",
"repo.settings.add_key_success": "The deploy key \"%s\" has been added.",
"repo.settings.deploy_key_deletion": "Remove Deploy Key",
"repo.settings.deploy_key_deletion_desc": "Removing a deploy key will revoke its access to this repository. Continue?",
"repo.settings.deploy_key_deletion_success": "The deploy key has been removed.",
"repo.settings.deploy_key_deletion_success": "Deploy key %s has been removed.",
"repo.settings.branches": "Branches",
"repo.settings.protected_branch": "Branch Protection",
"repo.settings.protected_branch.save_rule": "Save Rule",

View File

@@ -189,13 +189,7 @@ func repoAssignment() func(ctx *context.APIContext) {
repo.Owner = owner
ctx.Repo.Repository = repo
if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
ctx.Repo.Permission, err = access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
} else {
{
needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
@@ -228,7 +222,7 @@ func doerNeedTwoFactorAuth(ctx gocontext.Context, doer *user_model.User) (bool,
if !setting.TwoFactorAuthEnforced {
return false, nil
}
if doer == nil {
if doer == nil || !doer.IsIndividual() { // system doers like Actions tasks or deploy-keys can never enroll 2FA
return false, nil
}
has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, doer.ID)
@@ -1448,6 +1442,7 @@ func Routes() *web.Router {
m.Group("/keys", func() {
m.Combo("").Get(repo.ListDeployKeys).
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
m.Post("/tokens", bind(api.CreateDeployKeyTokenOption{}), repo.CreateDeployToken)
m.Combo("/{id}").Get(repo.GetDeployKey).
Delete(repo.DeleteDeployKey)
}, reqToken(), reqAdmin())

View File

@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1
import (
"testing"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDoerNeedTwoFactorAuth(t *testing.T) {
defer test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)()
for _, doer := range []*user_model.User{nil, user_model.NewActionsUser(), user_model.NewDeployKeyUser()} {
need, err := doerNeedTwoFactorAuth(t.Context(), doer)
require.NoError(t, err)
assert.False(t, need)
}
}

View File

@@ -11,6 +11,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
@@ -24,7 +25,7 @@ import (
)
// appendPrivateInformation appends the owner and key type information to api.PublicKey
func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *asymkey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *deploykey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
apiKey.ReadOnly = key.Mode == perm.AccessModeRead
if repository.ID == key.RepoID {
apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode})
@@ -78,14 +79,14 @@ func ListDeployKeys(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
opts := asymkey_model.ListDeployKeysOptions{
opts := deploykey_model.ListDeployKeysOptions{
ListOptions: utils.GetListOptions(ctx),
RepoID: ctx.Repo.Repository.ID,
KeyID: ctx.FormInt64("key_id"),
Fingerprint: ctx.FormString("fingerprint"),
}
keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts)
keys, count, err := db.FindAndCount[deploykey_model.DeployKey](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -133,7 +134,7 @@ func GetDeployKey(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
key, err := deploykey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
if err != nil {
ctx.APIErrorAuto(err)
return
@@ -160,13 +161,13 @@ func HandleCheckKeyStringError(ctx *context.APIContext, err error) {
// HandleAddKeyError handle add key error
func HandleAddKeyError(ctx *context.APIContext, err error) {
switch {
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
ctx.APIError(http.StatusUnprocessableEntity, "This key has already been added to this repository")
case asymkey_model.IsErrKeyAlreadyExist(err):
ctx.APIError(http.StatusUnprocessableEntity, "Key content has been used as non-deploy key")
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
ctx.APIError(http.StatusUnprocessableEntity, "Key title has been used")
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
case deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
ctx.APIError(http.StatusUnprocessableEntity, "A key with the same name already exists")
default:
ctx.APIErrorInternal(err)
@@ -213,7 +214,7 @@ func CreateDeployKey(ctx *context.APIContext) {
}
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
if err != nil {
HandleAddKeyError(ctx, err)
return
@@ -221,6 +222,49 @@ func CreateDeployKey(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
}
// CreateDeployToken create a deploy token for a repository
func CreateDeployToken(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/keys/tokens repository repoCreateDeployToken
// ---
// summary: Add a deploy token to a repository, it authenticates git over HTTPS
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/CreateDeployKeyTokenOption"
// responses:
// "201":
// "$ref": "#/responses/DeployKey"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
form := web.GetForm[*api.CreateDeployKeyTokenOption](ctx)
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
if err != nil {
HandleAddKeyError(ctx, err)
return
}
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
}
// DeleteDeployKey delete deploy key for a repository
func DeleteDeployKey(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey
@@ -251,7 +295,8 @@ func DeleteDeployKey(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil {
// a key that is already gone still leaves the caller with the state it asked for
if _, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) {
if asymkey_model.IsErrKeyAccessDenied(err) {
ctx.APIError(http.StatusForbidden, "You do not have access to this key")
} else {

View File

@@ -53,6 +53,9 @@ type swaggerParameterBodies struct {
// in:body
CreateKeyOption api.CreateKeyOption
// in:body
CreateDeployKeyTokenOption api.CreateDeployKeyTokenOption
// in:body
RenameUserOption api.RenameUserOption

View File

@@ -58,16 +58,13 @@ func Search(ctx *context.APIContext) {
uid := ctx.FormInt64("uid")
var users []*user_model.User
var maxResults int64
var err error
switch uid {
case user_model.GhostUserID:
maxResults = 1
users = []*user_model.User{user_model.NewGhostUser()}
case user_model.ActionsUserID:
maxResults = 1
users = []*user_model.User{user_model.NewActionsUser()}
default:
if uid < 0 {
_, sysUser, _ := user_model.GetPossibleUserByID(ctx, uid)
if sysUser != nil && sysUser.ID == uid {
maxResults = 1
users = []*user_model.User{sysUser}
}
} else {
opts := user_model.SearchUserOptions{
Actor: ctx.Doer,
Keyword: ctx.FormTrim("q"),
@@ -77,6 +74,7 @@ func Search(ctx *context.APIContext) {
ListOptions: listOptions,
}
opts.ApplyPublicOnly(ctx.PublicOnly)
var err error
users, maxResults, err = user_model.SearchUsers(ctx, opts)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{

View File

@@ -21,7 +21,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
t.Run("HTML", func(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}}
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
respContent := w.Body.String()
assert.Contains(t, respContent, `class="page-content status-page-500"`)
@@ -36,7 +36,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
t.Run("Plain", func(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}}
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderServiceUnavailable(w, req)
assert.Equal(t, "Service Unavailable", w.Body.String())
})

View File

@@ -4,18 +4,13 @@
package private
import (
"context"
"errors"
"fmt"
"net/http"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/cache"
"gitea.dev/modules/cachegroup"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
@@ -103,15 +98,11 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
return
}
ownerName := ctx.PathParam("owner")
repoName := ctx.PathParam("repo")
repo := loadRepository(ctx, ownerName, repoName)
if ctx.Written() {
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
return
}
// now, repo can't be nil
repo := ctx.Repo.Repository
// first, collect updates and sync branches
updates := hookPostReceiveCollectPushUpdates(opts, repo)
if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) {
@@ -144,17 +135,7 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
// Handle Push Options
if isPrivate.Has() || isTemplate.Has() {
pusher, err := loadContextCacheUser(ctx, opts.UserID)
if err != nil {
ctx.PrivateInternalErrorf("failed to load pusher user: %v", err)
return false
}
perm, err := access_model.GetDoerRepoPermission(ctx, repo, pusher)
if err != nil {
ctx.PrivateInternalErrorf("failed to load doer repo permission: %v", err)
return false
}
if !perm.IsOwner() && !perm.IsAdmin() {
if !ctx.Repo.Permission.IsAdmin() {
ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied")
return false
}
@@ -171,13 +152,13 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
// yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
repo.IsPrivate = isPrivate.Value()
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err)
}
}
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
repo.IsTemplate = isTemplate.Value()
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
log.Error("failed to update repo is_template: %v", err)
}
}
@@ -244,10 +225,6 @@ func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *
ctx.JSON(http.StatusOK, private.HookPostReceiveResult{Results: results})
}
func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
return cache.GetWithContextCache(ctx, cachegroup.User, id, user_model.GetUserByID)
}
// hookPostReceiveHandlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool {
if len(updates) == 0 {
@@ -261,15 +238,9 @@ func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext,
return false
}
pusher, err := loadContextCacheUser(ctx, opts.UserID)
if err != nil {
ctx.PrivateInternalErrorf("failed to load pusher user %d: %v", opts.UserID, err)
return false
}
// FIXME: Maybe we need a `PullRequestStatusMerged` status for PRs that are merged, currently we use the previous status
// here to keep it as before, that maybe PullRequestStatusMergeable
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), pusher, pr.Status)
_, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), ctx.Doer, pr.Status)
if err != nil {
ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err)
return false

View File

@@ -25,13 +25,14 @@ func TestHandlePullRequestMerging(t *testing.T) {
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
err = pull_model.ScheduleAutoMerge(t.Context(), user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
assert.NoError(t, err)
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
ctx, resp := contexttest.MockPrivateContext(t, "/")
ctx.Doer = user2
hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{
PullRequestID: pr.ID,
UserID: 2,

View File

@@ -8,11 +8,8 @@ import (
"net/http"
"os"
asymkey_model "gitea.dev/models/asymkey"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
@@ -27,29 +24,18 @@ import (
type preReceiveContext struct {
*gitea_context.PrivateContext
user *user_model.User // the "pusher", it's the org user if a DeployKey is used
userPerm access_model.Permission
deployKeyAccessMode perm_model.AccessMode
canCreatePullRequest bool
checkedCanCreatePullRequest bool
protectedTags []*git_model.ProtectedTag
gotProtectedTags bool
env []string
env []string
opts *private.HookOptions
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool
canCreatePullRequest *bool
protectedTags []*git_model.ProtectedTag
}
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
ctx.canWriteCodeUnitCached = &canWrite
ctx.canWriteCodeUnitCached = new(ctx.Repo.Permission.CanWrite(unit.TypeCode))
}
return *ctx.canWriteCodeUnitCached
}
@@ -63,7 +49,7 @@ func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if !refFullName.IsBranch() {
return false
}
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
return issues_model.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, refFullName.BranchName(), ctx.Doer)
}
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
@@ -80,11 +66,10 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
// CanCreatePullRequest returns true if pusher can create pull requests
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
if !ctx.checkedCanCreatePullRequest {
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
ctx.checkedCanCreatePullRequest = true
if ctx.canCreatePullRequest == nil {
ctx.canCreatePullRequest = new(ctx.Repo.Permission.CanRead(unit.TypePullRequests))
}
return ctx.canCreatePullRequest
return *ctx.canCreatePullRequest
}
// AssertCreatePullRequest returns true if can create pull requests
@@ -102,6 +87,9 @@ func (ctx *preReceiveContext) AssertCreatePullRequest() bool {
// HookPreReceive checks whether a individual commit is acceptable
func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts := web.GetForm[*private.HookOptions](ctx)
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
return
}
ourCtx := &preReceiveContext{
PrivateContext: ctx,
@@ -109,10 +97,6 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts: opts,
}
if !ourCtx.loadPusherAndPermission() {
return // if error occurs, loadPusherAndPermission had written the error response
}
// Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs {
oldCommitID := opts.OldCommitIDs[i]
@@ -236,7 +220,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
var canPush bool
if ctx.opts.DeployKeyID != 0 {
if ctx.opts.UserID == user_model.DeployKeyUserID {
// This flag is only ever true if protectBranch.CanForcePush is true
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
@@ -245,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
} else {
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
} else {
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
}
}
@@ -296,7 +280,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// Now check if the user is allowed to merge PRs for this repository
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.Repo.Permission, ctx.Doer)
if err != nil {
ctx.PrivateInternalErrorf("Error calculating if allowed to merge: %v", err)
return
@@ -308,7 +292,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
// If we can bypass branch protection we can ignore status checks, reviews and protected files
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.Doer, ctx.Repo.Permission.IsAdmin()) {
return
}
@@ -337,14 +321,14 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
tagName := refFullName.TagName()
if !ctx.gotProtectedTags {
if ctx.protectedTags == nil {
var err error
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get protected tags: %v", err)
return
}
ctx.gotProtectedTags = true
ctx.protectedTags = util.SliceNilAsEmpty(ctx.protectedTags)
}
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
@@ -399,45 +383,3 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
}
return env
}
// loadPusherAndPermission returns false if an error occurs, and it writes the error response
func (ctx *preReceiveContext) loadPusherAndPermission() bool {
if ctx.opts.UserID == user_model.ActionsUserID {
taskID := ctx.opts.ActionsTaskID
ctx.user = user_model.NewActionsUserWithTaskID(taskID)
if taskID == 0 {
ctx.PrivateUserErrorf(http.StatusInternalServerError, "ActionsUser with task ID 0")
return false
}
userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err)
return false
}
ctx.userPerm = userPerm
} else {
user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
return false
}
ctx.user = user
userPerm, err := access_model.GetDoerRepoPermission(ctx, ctx.Repo.Repository, user)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err)
return false
}
ctx.userPerm = userPerm
}
if ctx.opts.DeployKeyID != 0 {
deployKey, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.opts.DeployKeyID)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err)
return false
}
ctx.deployKeyAccessMode = deployKey.Mode
}
return true
}

View File

@@ -7,7 +7,6 @@ import (
"testing"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
@@ -19,7 +18,7 @@ import (
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
@@ -45,16 +44,12 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
maintainer := baseRepo.Owner
headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
require.NoError(t, err)
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{
PrivateContext: mockCtx,
user: maintainer,
userPerm: headPerm,
}
ctx := &preReceiveContext{PrivateContext: mockCtx}
ctx.SetPathParam("owner", headRepo.OwnerName)
ctx.SetPathParam("repo", headRepo.Name)
RepoAssignment(ctx.PrivateContext)
loadContextDoerPermission(ctx.PrivateContext, baseRepo.OwnerID, "")
// The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))

View File

@@ -23,8 +23,17 @@ func HookProcReceive(ctx *gitea_context.PrivateContext) {
ctx.Status(http.StatusNotFound)
return
}
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
return
}
results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, opts)
results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, &agit.ProcReceiveOptions{
OldCommitIDs: opts.OldCommitIDs,
NewCommitIDs: opts.NewCommitIDs,
RefFullNames: opts.RefFullNames,
GitPushOptions: opts.GitPushOptions,
Doer: ctx.Doer,
})
if err != nil {
if errors.Is(err, issues_model.ErrMustCollaborator) {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "You must be a collaborator to create pull request.")

View File

@@ -80,7 +80,7 @@ func Routes() *web.Router {
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), bind(private.HookOptions{}), HookPostReceive)
r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookPostReceive)
r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
r.Get("/serv/none/{keyid}", ServNoCommand)
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)

View File

@@ -4,7 +4,9 @@
package private
import (
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/user"
"gitea.dev/modules/git"
gitea_context "gitea.dev/services/context"
)
@@ -27,10 +29,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) {
ctx.PrivateInternalErrorf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
return
}
ctx.Repo = &gitea_context.Repository{
Repository: repo,
GitRepo: gitRepo,
}
ctx.Repo = &gitea_context.Repository{Repository: repo, GitRepo: gitRepo}
}
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository {
@@ -44,3 +43,18 @@ func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName strin
}
return repo
}
func loadContextDoerPermission(ctx *gitea_context.PrivateContext, userID int64, extDoerData string) bool {
doer, err := user.GetDoerUser(ctx, userID, extDoerData)
if err != nil {
ctx.PrivateInternalErrorf("Failed to get user: %d, error: %v", userID, err)
return false
}
ctx.Doer = doer
ctx.Repo.Permission, err = access.GetDoerRepoPermission(ctx, ctx.Repo.Repository, doer)
if err != nil {
ctx.PrivateInternalErrorf("Failed to get permission for user: %d, error: %v", userID, err)
return false
}
return true
}

View File

@@ -7,7 +7,7 @@ import (
"net/http"
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/modules/timeutil"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/services/context"
)
@@ -20,17 +20,16 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
return
}
deployKey, err := asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
deployKey, err := deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
if err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) {
if deploykey_model.IsErrDeployKeyNotExist(err) {
ctx.PlainText(http.StatusOK, "success")
return
}
ctx.PrivateInternalErrorf("%v", err)
return
}
deployKey.UpdatedUnix = timeutil.TimeStampNow()
if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil {
if err = deploykey_model.UpdateDeployKeyLastUsed(ctx, deployKey.ID); err != nil {
ctx.PrivateInternalErrorf("%v", err)
return
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
asymkey_model "gitea.dev/models/asymkey"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
@@ -73,9 +74,9 @@ func ServCommand(ctx *context.PrivateContext) {
// Set the basic parts of the results to return
results := private.ServCommandResults{
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
KeyID: keyID,
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
PublicKeyID: keyID,
}
repoLogName := reqOwnerName + "/" + reqRepoName
@@ -184,40 +185,25 @@ func ServCommand(ctx *context.PrivateContext) {
ctx.PrivateInternalErrorf("Unable to get key: %d, error: %v", keyID, err)
return
}
results.KeyName = key.Name
results.KeyID = key.ID
results.UserID = key.OwnerID
results.PublicKeyID = key.ID
// Deploy Keys have ownerID set to 0 therefore we can't use the owner
// So now we need to check if the key is a deploy key
// We'll keep hold of the deploy key here for permissions checking
var deployKey *asymkey_model.DeployKey
var deployKey *deploykey_model.DeployKey
var user *user_model.User
if key.Type == asymkey_model.KeyTypeDeploy {
if repo == nil {
ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName)
return
}
deployKey, err = asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
deployKey, err = deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
if err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) {
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
if deploykey_model.IsErrDeployKeyNotExist(err) {
ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy-key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
return
}
ctx.PrivateInternalErrorf("Unable to get deploy for public (deploy) key %d for %s, error: %v", key.ID, repoLogName, err)
return
}
results.DeployKeyID = deployKey.ID
results.KeyName = deployKey.Name
// FIXME: Deploy keys aren't really the owner of the repo pushing changes
// however we don't have good way of representing deploy keys in hook.go
// so for now use the owner of the repository
results.UserName = results.OwnerName
results.UserID = repo.OwnerID
if !repo.Owner.KeepEmailPrivate {
results.UserEmail = repo.Owner.Email
}
user = user_model.NewDeployKeyUserWithKeyID(deployKey.ID)
} else {
// Get the user represented by the Key
user, err = user_model.GetUserByID(ctx, key.OwnerID)
@@ -229,16 +215,19 @@ func ServCommand(ctx *context.PrivateContext) {
ctx.PrivateInternalErrorf("Unable to get key owner %d for public key %d:%s, error: %v", key.OwnerID, key.ID, key.Name, err)
return
}
if !user.IsActive || user.ProhibitLogin {
ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.")
return
}
}
results.UserName = user.Name
if !user.KeepEmailPrivate {
results.UserEmail = user.Email
}
results.UserID = user.ID
results.UserName = user.Name
if !user.KeepEmailPrivate {
results.UserEmail = user.Email
}
if user.ExtDoerData != nil {
results.UserExtDoerData = user.ExtDoerData.EncodeToString()
}
// Don't allow pushing if the repo is archived
@@ -252,37 +241,29 @@ func ServCommand(ctx *context.PrivateContext) {
(mode > perm.AccessModeRead ||
repo.IsPrivate ||
owner.Visibility.IsPrivate() ||
(user != nil && user.IsRestricted) || // user will be nil if the key is a deploy key
user.IsRestricted ||
setting.Service.RequireSignInViewStrict) {
if key.Type == asymkey_model.KeyTypeDeploy {
if deployKey == nil || deployKey.Mode < mode {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
return
}
} else {
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
mode = perm.AccessModeRead
}
// Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
// AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
// The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
// Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
mode = perm.AccessModeRead
}
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
return
}
userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
return
}
userMode := userPerm.UnitAccessMode(unitType)
if userMode < mode {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User %d with key %d:%s has no %q permission for %s", key.OwnerID, key.ID, key.Name, modeString, repoLogName)
return
}
userMode := userPerm.UnitAccessMode(unitType)
if userMode < mode {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "User key %d:%s has no %q permission for %s", key.ID, key.Name, modeString, repoLogName)
return
}
}
// We already know we aren't using a deploy key
if repo == nil {
if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
ctx.PrivateUserErrorf(http.StatusForbidden, "Push to create is not enabled for organizations.")

View File

@@ -212,7 +212,7 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
resp := httptest.NewRecorder()
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil)
ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(t, resp, req), nil, nil)
ctx.Repo.Repository = &repo_model.Repository{
OwnerName: "user1",
Name: "repo1",

View File

@@ -163,7 +163,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && !ctx.Doer.IsGiteaActions() {
if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && ctx.Doer.IsIndividual() {
_, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if err == nil {
// TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
@@ -252,7 +252,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
var environ []string
if !isPull {
// if not "pull", then must be "push", and doer must exist
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
}

View File

@@ -9,7 +9,9 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
@@ -17,23 +19,20 @@ import (
"gitea.dev/services/forms"
)
// DeployKeys render the deploy-keys list of a repository page
func DeployKeys(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets")
ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
ctx.Data["PageIsSettingsKeys"] = true
ctx.Data["DisableSSH"] = setting.SSH.Disabled
keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
keys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
if err != nil {
ctx.ServerError("ListDeployKeys", err)
return
}
ctx.Data["RepoDeployKeys"] = keys
ctx.HTML(http.StatusOK, tplDeployKeys)
}
// DeployKeysPost response for adding a deploy-key of a repository
func DeployKeysPost(ctx *context.Context) {
form := context.GetFetchActionForm[*forms.AddKeyForm](ctx)
if form == nil {
@@ -54,16 +53,14 @@ func DeployKeysPost(ctx *context.Context) {
}
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
if err != nil {
switch {
case asymkey_model.IsErrDeployKeyAlreadyExist(err):
case deploykey_model.IsErrDeployKeyAlreadyExist(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content")
case asymkey_model.IsErrKeyAlreadyExist(err):
ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content")
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
case asymkey_model.IsErrKeyNameAlreadyUsed(err), deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
default:
ctx.ServerError("AddDeployKey", err)
@@ -75,12 +72,50 @@ func DeployKeysPost(ctx *context.Context) {
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
}
// DeleteDeployKey response for deleting a deploy-key
func DeleteDeployKey(ctx *context.Context) {
if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil {
key, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id"))
if err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) { // a key that is already gone leaves the caller with the state it asked for
ctx.ServerError("DeleteDeployKey", err)
} else {
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
return
}
if key != nil {
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success", key.Name))
}
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
}
func DeployKeyGenerateToken(ctx *context.Context) {
form := context.GetFetchActionForm[*forms.AddDeployTokenForm](ctx)
if form == nil {
return
}
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
if err != nil {
if deploykey_model.IsErrDeployKeyNameAlreadyUsed(err) {
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
} else {
ctx.ServerError("AddDeployToken", err)
}
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.generate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
}
func DeployKeyRegenerateToken(ctx *context.Context) {
key, err := deploykey_model.RegenerateDeployKeyToken(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id"))
if err != nil {
if deploykey_model.IsErrDeployKeyNotExist(err) {
ctx.JSONErrorNotFound()
} else {
ctx.ServerError("RegenerateDeployToken", err)
}
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.regenerate_deploy_token_success", htmlutil.HTMLFormat("<code>%s</code>", key.Token)))
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
}

View File

@@ -8,7 +8,7 @@ import (
"net/url"
"testing"
asymkey_model "gitea.dev/models/asymkey"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
@@ -38,7 +38,7 @@ func TestAddDeployKey(t *testing.T) {
contexttest.LoadRepo(t, ctx, 2)
DeployKeysPost(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
})
t.Run("ReadWrite", func(t *testing.T) {
const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n"
@@ -47,7 +47,7 @@ func TestAddDeployKey(t *testing.T) {
contexttest.LoadRepo(t, ctx, 2)
DeployKeysPost(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
})
}

View File

@@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
type AuthMiddleware struct {
AllowOAuth2 types.PreMiddlewareProvider
AllowBasic types.PreMiddlewareProvider
AllowDeployToken types.PreMiddlewareProvider
MiddlewareHandler func(*context.Context)
}
func newWebAuthMiddleware() *AuthMiddleware {
type keyAllowOAuth2 struct{}
type keyAllowBasic struct{}
type keyAllowDeployToken struct{}
webAuth := &AuthMiddleware{}
middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider {
@@ -114,11 +116,13 @@ func newWebAuthMiddleware() *AuthMiddleware {
webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true)
webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true)
webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true)
enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext())
webAuth.MiddlewareHandler = func(ctx *context.Context) {
allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true
allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true
allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true
group := auth_service.NewGroup()
@@ -127,13 +131,16 @@ func newWebAuthMiddleware() *AuthMiddleware {
if allowOAuth2 {
group.Add(&auth_service.OAuth2{})
}
if allowDeployToken {
group.Add(&auth_service.DeployToken{}) // before Basic, which would try the token as a password
}
if allowBasic {
group.Add(&auth_service.Basic{})
}
// Sessionless means the route's auth can be done without web ui, then it doesn't need to create a session
// For example: accessing git via http, access rss feeds, downloading attachments, etc
isSessionless := allowOAuth2 || allowBasic
isSessionless := allowOAuth2 || allowBasic || allowDeployToken
if setting.Service.EnableReverseProxyAuth {
// reverse-proxy should before Session, otherwise the header will be ignored if user has login
@@ -1223,6 +1230,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/keys", func() {
m.Combo("").Get(repo_setting.DeployKeys).
Post(repo_setting.DeployKeysPost)
m.Post("/generate-token", repo_setting.DeployKeyGenerateToken)
m.Post("/regenerate-token", repo_setting.DeployKeyRegenerateToken)
m.Post("/delete", repo_setting.DeleteDeployKey)
})
@@ -1743,12 +1752,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// git lfs uses its own jwt key, and it handles the token & auth by itself, it conflicts with the general "OAuth2" auth method
// pattern: "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, repo.CorsHandler(), optSignInFromAnyOrigin)
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin)
// Some users want to use "web-based git client" to access Gitea's repositories,
// so the CORS handler and OPTIONS method are used.
// pattern: "/{username}/{reponame}/{git-paths}": git http support
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
m.Group("/notifications", func() {
m.Get("", user.Notifications)

View File

@@ -121,7 +121,7 @@ func (input *notifyInput) Notify(ctx context.Context) {
func notify(ctx context.Context, input *notifyInput) error {
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
if input.Doer.IsGiteaActions() {
if input.Doer.ID == user_model.ActionsUserID {
// avoiding triggering cyclically, for example:
// a comment of an issue will trigger the runner to add a new comment as reply,
// and the new comment will trigger the runner again.

View File

@@ -60,8 +60,18 @@ func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string)
return "", "", util.NewNotExistErrorf("base branch does not exist")
}
type ProcReceiveOptions struct {
OldCommitIDs []string
NewCommitIDs []string
RefFullNames []git.RefName
GitPushOptions private.GitPushOptions
Doer *user_model.User
}
// ProcReceive handle proc receive work
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *ProcReceiveOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
topicBranch := opts.GitPushOptions["topic"]
@@ -72,12 +82,9 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
userName := strings.ToLower(opts.UserName)
pusher, err := user_model.GetUserByID(ctx, opts.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get user. Error: %w", err)
}
pusher := opts.Doer
userName := strings.ToLower(pusher.Name)
for i := range opts.OldCommitIDs {
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {

View File

@@ -9,12 +9,13 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
repo_model "gitea.dev/models/repo"
)
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
deployKeys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: repoID})
deployKeys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: repoID})
if err != nil {
return 0, fmt.Errorf("listDeployKeys: %w", err)
}
@@ -28,13 +29,17 @@ func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
}
// deleteDeployKeyFromDB delete deploy keys from database
func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) error {
if _, err := db.DeleteByID[asymkey_model.DeployKey](ctx, key.ID); err != nil {
func deleteDeployKeyFromDB(ctx context.Context, key *deploykey_model.DeployKey) error {
if _, err := db.DeleteByID[deploykey_model.DeployKey](ctx, key.ID); err != nil {
return fmt.Errorf("delete deploy key [%d]: %w", key.ID, err)
}
if key.KeyType == deploykey_model.KeyTypeToken { // a token has no public key to clean up
return nil
}
// Check if this is the last reference to same key content.
has, err := asymkey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
has, err := deploykey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
if err != nil {
return err
} else if !has {
@@ -46,21 +51,22 @@ func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) er
return nil
}
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed.
// Permissions check should be done outside.
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
key, err := asymkey_model.GetDeployKeyByID(ctx, repo.ID, id)
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed,
// and returns the key it deleted. Permissions check should be done outside.
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) (*deploykey_model.DeployKey, error) {
deleted, err := db.WithTx2(ctx, func(ctx context.Context) (*deploykey_model.DeployKey, error) {
key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, id)
if err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) {
return nil
}
return fmt.Errorf("GetDeployKeyByID: %w", err)
return nil, err
}
return deleteDeployKeyFromDB(ctx, key)
}); err != nil {
return err
return key, deleteDeployKeyFromDB(ctx, key)
})
if err != nil {
return nil, err
}
if deleted.KeyType == deploykey_model.KeyTypeToken {
return deleted, nil // a token never appears in the authorized_keys file
}
return RewriteAllPublicKeys(ctx)
return deleted, RewriteAllPublicKeys(ctx)
}

View File

@@ -29,6 +29,7 @@ const (
AccessTokenMethodName = "access_token"
OAuth2TokenMethodName = "oauth2_token"
ActionTokenMethodName = "action_token"
DeployTokenMethodName = "deploy_token"
)
// Basic implements the Auth interface and authenticates requests (API requests
@@ -41,7 +42,7 @@ func (b *Basic) Name() string {
return BasicMethodName
}
func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return ret
@@ -53,7 +54,7 @@ func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname,
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// Check if username or password is a token
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
isUsernameToken := passwd == "" || passwd == "x-oauth-basic"
// Assume username is token
authToken := uname
if !isUsernameToken {
@@ -122,7 +123,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
// name/token on successful validation.
// Returns nil if header is empty or validation fails.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
parseBasicRet := b.parseAuthBasic(req)
parseBasicRet := parseAuthBasic(req)
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
if authToken == "" && uname == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable

View File

@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
deploykey_model "gitea.dev/models/deploykey"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
)
var _ Method = &DeployToken{}
// DeployToken authenticates a deploy key token given as HTTP basic auth credential.
// Only add it to an auth group where a repo scoped credential makes sense.
type DeployToken struct{}
func (d *DeployToken) Name() string {
return DeployTokenMethodName
}
// Verify returns a user that stands for the deploy key alone. Its permissions come from the key,
// see access_model.getDeployKeyRepoPermission, so the request can never reach another repository
// or exceed the access mode of the key.
func (d *DeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) {
authToken := parseAuthBasic(req).authToken
if authToken == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
key, err := deploykey_model.VerifyDeployKeyToken(req.Context(), authToken)
if err != nil {
if deploykey_model.IsErrDeployKeyNotExist(err) {
return nil, nil //nolint:nilnil // not a deploy token, let the other methods try
}
return nil, err
}
if err := deploykey_model.UpdateDeployKeyLastUsed(req.Context(), key.ID); err != nil {
log.Error("UpdateDeployKeyUpdated: %v", err)
}
store.GetData()["LoginMethod"] = DeployTokenMethodName
return user_model.NewDeployKeyUserWithKeyID(key.ID), nil
}

View File

@@ -225,11 +225,11 @@ func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
return b
}
func NewBaseContextForTest(resp http.ResponseWriter, req *http.Request) *Base {
func NewBaseContextForTest(t reqctx.TestingT, resp http.ResponseWriter, req *http.Request) *Base {
if !setting.IsInTesting {
panic("This function is only for testing")
}
ctx := reqctx.NewRequestContextForTest(req.Context())
ctx := reqctx.NewRequestContextForTest(t)
*req = *req.WithContext(ctx)
return NewBaseContext(resp, req)
}

View File

@@ -29,7 +29,7 @@ func TestRedirect(t *testing.T) {
}
for _, c := range cases {
resp := httptest.NewRecorder()
b := NewBaseContextForTest(resp, req)
b := NewBaseContextForTest(t, resp, req)
resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String())
b.Redirect(c.url)
has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy"
@@ -39,7 +39,7 @@ func TestRedirect(t *testing.T) {
req, _ = http.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
req.Header.Add("X-Gitea-Fetch-Action", "1")
b := NewBaseContextForTest(resp, req)
b := NewBaseContextForTest(t, resp, req)
b.Redirect("/other")
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())

View File

@@ -42,7 +42,7 @@ func TestRedirectToCurrentSite(t *testing.T) {
t.Run(c.location, func(t *testing.T) {
req := &http.Request{URL: &url.URL{Path: "/"}}
resp := httptest.NewRecorder()
base := NewBaseContextForTest(resp, req)
base := NewBaseContextForTest(t, resp, req)
ctx := NewWebContext(base, nil, nil)
ctx.RedirectToCurrentSite(c.location)
redirect := test.RedirectURL(resp)
@@ -58,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(t), req)
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"time"
user_model "gitea.dev/models/user"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
@@ -23,6 +24,7 @@ type PrivateContext struct {
*Base
Override context.Context
Doer *user_model.User
Repo *Repository
}

View File

@@ -42,7 +42,7 @@ func mockRequest(t *testing.T, reqPath string) *http.Request {
requestURL, err := url.Parse(path)
assert.NoError(t, err)
req := &http.Request{Method: method, Host: requestURL.Host, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}}
req = req.WithContext(reqctx.NewRequestContextForTest(req.Context()))
req = req.WithContext(reqctx.NewRequestContextForTest(t))
return req
}

View File

@@ -20,6 +20,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
@@ -846,19 +847,21 @@ func ToGitHook(h *git.Hook) *api.GitHook {
}
}
// ToDeployKey convert asymkey_model.DeployKey to api.DeployKey
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *asymkey_model.DeployKey) *api.DeployKey {
// ToDeployKey convert deploykey_model.DeployKey to api.DeployKey
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *deploykey_model.DeployKey) *api.DeployKey {
k := &api.DeployKey{
ID: deployKey.ID,
KeyID: deployKey.KeyID,
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
Title: deployKey.Name,
Created: deployKey.CreatedUnix.AsTime(),
ReadOnly: deployKey.Mode == perm.AccessModeRead, // All deploy keys are read-only.
ID: deployKey.ID,
KeyType: util.Iif(deployKey.KeyType == deploykey_model.KeyTypeSSH, "ssh", "token"),
KeyID: deployKey.KeyID,
Token: deployKey.Token,
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
Title: deployKey.Name,
Fingerprint: deployKey.Fingerprint,
Created: deployKey.CreatedUnix.AsTime(),
ReadOnly: deployKey.IsReadOnly(),
}
if err := deployKey.LoadPublicKey(ctx); err == nil {
if deployKey.KeyType == deploykey_model.KeyTypeSSH && deployKey.LoadPublicKey(ctx) == nil {
k.Key = deployKey.PublicKey.Content
k.Fingerprint = deployKey.PublicKey.Fingerprint
}
return k
}

View File

@@ -576,3 +576,10 @@ type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
// AddDeployTokenForm form for adding a deploy token to a repository
type AddDeployTokenForm struct {
middleware.FormDefaultValidator
Title string `binding:"Required;MaxSize(50)"`
IsWritable bool
}

View File

@@ -49,16 +49,18 @@ type requestContext struct {
// Claims is a JWT Token Claims
type Claims struct {
RepoID int64
Op string
UserID int64
RepoID int64
Op string
UserID int64
UserExtDoerData string
jwt.RegisteredClaims
}
type AuthTokenOptions struct {
Op string
UserID int64
RepoID int64
Op string
UserID int64
UserExtDoerData string
RepoID int64
}
func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
@@ -68,9 +70,10 @@ func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
RepoID: opts.RepoID,
Op: opts.Op,
UserID: opts.UserID,
UserExtDoerData: opts.UserExtDoerData,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -544,15 +547,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
accessMode = perm_model.AccessModeWrite
}
if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
perm, err := access_model.GetActionsUserRepoPermission(ctx, repository, ctx.Doer, taskID)
if err != nil {
log.Error("Unable to GetActionsUserRepoPermission for task[%d] Error: %v", taskID, err)
return false
}
return perm.CanAccess(accessMode, unit.TypeCode)
}
// it works for both anonymous request and signed-in user, then perm.CanAccess will do the permission check
perm, err := access_model.GetDoerRepoPermission(ctx, repository, ctx.Doer)
if err != nil {
@@ -604,9 +598,9 @@ func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repo
return nil, errors.New("invalid token claim")
}
u, err := user_model.GetUserByID(ctx, claims.UserID)
u, err := user_model.GetDoerUser(ctx, claims.UserID, claims.UserExtDoerData)
if err != nil {
log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
log.Error("Unable to GetDoerUser[%d]: Error: %v", claims.UserID, err)
return nil, err
}
if !u.IsActive || u.ProhibitLogin {

View File

@@ -8,6 +8,7 @@ import (
"testing"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -101,4 +102,23 @@ func TestAuthenticate(t *testing.T) {
err := handleLFSTokenTestPerm("upload", 2, repo1, perm_model.AccessModeWrite)
assert.NoError(t, err)
})
// a deploy-key doer has no user row, so the token must carry its ext doer data to stay redeemable
t.Run("handleLFSToken resolves deploy-key doers", func(t *testing.T) {
key, err := deploykey_model.AddDeployKeyToken(t.Context(), repo1.ID, "lfs", perm_model.AccessModeRead)
require.NoError(t, err)
doer := user_model.NewDeployKeyUserWithKeyID(key.ID)
getDoerToken := func(op string) string {
s, _ := GetLFSAuthTokenWithBearer(AuthTokenOptions{Op: op, UserID: doer.ID, UserExtDoerData: doer.ExtDoerData.EncodeToString(), RepoID: repo1.ID})
_, token, _ := strings.Cut(s, " ")
return token
}
u, err := handleLFSToken(ctx, getDoerToken("download"), repo1, perm_model.AccessModeRead)
require.NoError(t, err)
assert.Equal(t, user_model.DeployKeyUserID, u.ID)
_, err = handleLFSToken(ctx, getDoerToken("upload"), repo1, perm_model.AccessModeWrite)
assert.ErrorContains(t, err, "no permission to access the repository")
})
}

View File

@@ -38,7 +38,7 @@ func TestRenderHelperMention(t *testing.T) {
// when using web context, use user.IsUserVisibleToViewer to check
req, err := http.NewRequest(http.MethodGet, "/", nil)
assert.NoError(t, err)
base := gitea_context.NewBaseContextForTest(httptest.NewRecorder(), req)
base := gitea_context.NewBaseContextForTest(t, httptest.NewRecorder(), req)
giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))

View File

@@ -96,13 +96,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
if opts.RefFullName.IsTag() {
if pusher == nil || pusher.ID != opts.PusherID {
if opts.PusherID == user_model.ActionsUserID {
pusher = user_model.NewActionsUser()
} else {
var err error
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
return err
}
if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
return err
}
}
tagName := opts.RefFullName.TagName()
@@ -143,13 +138,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
}
} else if opts.RefFullName.IsBranch() {
if pusher == nil || pusher.ID != opts.PusherID {
if opts.PusherID == user_model.ActionsUserID {
pusher = user_model.NewActionsUser()
} else {
var err error
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
return err
}
if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
return err
}
}

View File

@@ -0,0 +1,44 @@
{{if .RepoDeployKeys}}
<div class="flex-divided-list items-with-main">
{{range $key := .RepoDeployKeys}}
<div class="item">
<div class="item-leading">
{{$usedRecently := and $key.HasUsed $key.HasRecentActivity}}
<span class="{{if $usedRecently}}tw-text-green{{end}}" {{if $usedRecently}}data-tooltip-content="{{ctx.Locale.Tr "settings.key_state_desc"}}"{{end}}>
{{svg (Iif $key.IsKeyTypeToken "octicon-key-asterisk" "octicon-key") 32}}
</span>
</div>
<div class="item-main">
<div class="item-title">{{$key.Name}}</div>
{{if $key.Fingerprint}}
<div class="item-body">{{$key.Fingerprint}}</div>
{{end}}
<div class="item-body">
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $key.CreatedUnix)}}
·
{{if $key.HasUsed}}{{ctx.Locale.Tr "settings.last_used"}}
<span {{if $key.HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort $key.UpdatedUnix}}</span>
{{else}}
{{ctx.Locale.Tr "settings.no_activity"}}
{{end}}
·
{{ctx.Locale.Tr "settings.can_read_info"}}
{{if not $key.IsReadOnly}} · {{ctx.Locale.Tr "settings.can_write_info"}}{{end}}
</div>
</div>
<div class="item-trailing">
{{if $key.IsKeyTypeToken}}
<button class="ui tiny button link-action" data-modal-confirm="#repo-deploy-token-regenerate-modal" data-url="{{ctx.RootData.Link}}/regenerate-token?id={{$key.ID}}">
{{svg "octicon-sync"}} {{ctx.Locale.Tr "settings.regenerate_token"}}
</button>
{{end}}
<button class="ui red tiny button link-action" data-modal-confirm="#repo-deploy-key-delete-modal" data-url="{{ctx.RootData.Link}}/delete?id={{$key.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
</div>
{{end}}
</div>
{{else}}
{{ctx.Locale.Tr "repo.settings.no_deploy_keys"}}
{{end}}

View File

@@ -1,92 +1,75 @@
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings")}}
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.deploy_keys"}}
<div class="ui right">
<div class="repo-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.deploy_keys"}}
<div class="ui right">
{{if not .DisableSSH}}
<button class="ui primary tiny show-panel toggle button" data-panel="#add-deploy-key-panel">{{ctx.Locale.Tr "repo.settings.add_deploy_key"}}</button>
<button class="ui primary compact tiny button show-panel toggle" data-panel="#add-deploy-key-ssh-panel" data-panel-hide="#add-deploy-key-token-panel">{{ctx.Locale.Tr "repo.settings.add_deploy_key_ssh"}}</button>
{{else}}
<button class="ui primary tiny button disabled">{{ctx.Locale.Tr "settings.ssh_disabled"}}</button>
{{end}}
</div>
</h4>
<div class="ui attached segment">
<div class="tw-hidden tw-mb-4" id="add-deploy-key-panel">
<form class="ui form form-fetch-action" action="{{.Link}}" method="post">
<div class="field">
{{ctx.Locale.Tr "repo.settings.deploy_key_desc"}}
</div>
<div class="field">
<label for="ssh-key-title">{{ctx.Locale.Tr "repo.settings.title"}}</label>
<input id="ssh-key-title" name="title" value="{{.title}}" autofocus required>
</div>
<div class="field">
<label for="ssh-key-content">{{ctx.Locale.Tr "repo.settings.deploy_key_content"}}</label>
<textarea id="ssh-key-content" name="content" placeholder="{{ctx.Locale.Tr "settings.key_content_ssh_placeholder"}}" required>{{.content}}</textarea>
</div>
<div class="field">
<div class="ui checkbox">
<input id="ssh-key-is-writable" name="is_writable" type="checkbox" value="1">
<label for="ssh-key-is-writable">
{{ctx.Locale.Tr "repo.settings.is_writable"}}
</label>
<small class="tw-pl-[26px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
</div>
</div>
<button class="ui primary button">
{{ctx.Locale.Tr "repo.settings.add_deploy_key"}}
</button>
<button class="ui hide-panel button" data-panel="#add-deploy-key-panel">
{{ctx.Locale.Tr "cancel"}}
</button>
</form>
</div>
{{if .RepoDeployKeys}}
<div class="flex-divided-list items-with-main">
{{range $deployKey := .RepoDeployKeys}}
<div class="item">
<div class="item-leading">
<span class="{{if $deployKey.HasRecentActivity}}tw-text-green{{end}}"
{{if $deployKey.HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.key_state_desc"}}"{{end}}
>{{svg "octicon-key" 32}}</span>
</div>
<div class="item-main">
<div class="item-title">{{$deployKey.Name}}</div>
<div class="item-body">
{{$deployKey.Fingerprint}}
</div>
<div class="item-body">
{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort $deployKey.CreatedUnix)}}
<span class="tw-mx-2">-</span>
{{svg "octicon-info"}}
{{if $deployKey.HasUsed}}
{{ctx.Locale.Tr "settings.last_used"}}
<span {{if $deployKey.HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort $deployKey.UpdatedUnix}}</span>
{{else}}
{{ctx.Locale.Tr "settings.no_activity"}}
{{end}}
<span class="tw-mx-2">-</span>
<span>{{ctx.Locale.Tr "settings.can_read_info"}}{{if not $deployKey.IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}}</span>
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button link-action" data-modal-confirm="#repo-deploy-key-delete-modal" data-url="{{$.Link}}/delete?id={{$deployKey.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}}
</button>
</div>
</div>
{{end}}
</div>
{{else}}
{{ctx.Locale.Tr "repo.settings.no_deploy_keys"}}
<button class="ui primary compact tiny button disabled">{{ctx.Locale.Tr "settings.ssh_disabled"}}</button>
{{end}}
<button class="ui primary compact tiny button show-panel toggle" data-panel="#add-deploy-key-token-panel" data-panel-hide="#add-deploy-key-ssh-panel">{{ctx.Locale.Tr "repo.settings.generate_deploy_token"}}</button>
</div>
</h4>
<div class="ui attached segment">
<div class="tw-hidden tw-mb-4" id="add-deploy-key-ssh-panel">
<form class="ui form form-fetch-action" action="{{.Link}}" method="post">
<div class="field">{{ctx.Locale.Tr "repo.settings.deploy_key_ssh_desc"}}</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.title"}}</label>
<input name="title" autofocus required>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "settings.key_content"}}</label>
<textarea name="content" placeholder="{{ctx.Locale.Tr "settings.key_content_ssh_placeholder"}}" required></textarea>
</div>
<div class="field">
<div class="ui checkbox">
<input name="is_writable" type="checkbox" value="1">
<label>{{ctx.Locale.Tr "repo.settings.is_writable"}}</label>
<small class="tw-pl-[20px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
</div>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.add_deploy_key_ssh"}}</button>
<button class="ui hide-panel button" data-panel="#add-deploy-key-ssh-panel">{{ctx.Locale.Tr "cancel"}}</button>
</form>
<div class="divider"></div>
</div>
<div class="tw-hidden tw-mb-4" id="add-deploy-key-token-panel">
<form class="ui form form-fetch-action" action="{{.Link}}/generate-token" method="post">
<div class="field">{{ctx.Locale.Tr "repo.settings.deploy_key_token_desc"}}</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.title"}}</label>
<input name="title" autofocus required>
</div>
<div class="field">
<div class="ui checkbox">
<input name="is_writable" type="checkbox" value="1">
<label>{{ctx.Locale.Tr "repo.settings.is_writable"}}</label>
<small class="tw-pl-[20px]">{{ctx.Locale.Tr "repo.settings.is_writable_info"}}</small>
</div>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.generate_deploy_token"}}</button>
<button class="ui hide-panel button" data-panel="#add-deploy-key-token-panel">{{ctx.Locale.Tr "cancel"}}</button>
</form>
<div class="divider"></div>
</div>
{{template "repo/settings/deploy_key_list" dict "RepoDeployKeys" .RepoDeployKeys}}
</div>
</div>
<div class="ui small modal" id="repo-deploy-key-delete-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.settings.deploy_key_deletion"}}</div>
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "remove"}}</div>
<div class="content"><p>{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}</p></div>
{{template "base/modal_actions_confirm" .}}
</div>
<div class="ui small modal" id="repo-deploy-token-regenerate-modal">
<div class="header">{{svg "octicon-sync"}} {{ctx.Locale.Tr "settings.regenerate_token"}}</div>
<div class="content"><p>{{ctx.Locale.Tr "repo.settings.regenerate_deploy_token_desc"}}</p></div>
{{template "base/modal_actions_confirm" .}}
</div>
{{template "repo/settings/layout_footer" .}}

View File

@@ -3973,6 +3973,26 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"CreateDeployKeyTokenOption": {
"properties": {
"read_only": {
"description": "Describe if the token has only read access or read/write",
"type": "boolean",
"x-go-name": "ReadOnly"
},
"title": {
"description": "Title of the token to add",
"type": "string",
"uniqueItems": true,
"x-go-name": "Title"
}
},
"required": [
"title"
],
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"CreateEmailOption": {
"description": "CreateEmailOption options when creating email addresses",
"properties": {
@@ -4227,7 +4247,6 @@
"x-go-package": "gitea.dev/modules/structs"
},
"CreateKeyOption": {
"description": "CreateKeyOption options when creating a key",
"properties": {
"key": {
"description": "An armored SSH key to add",
@@ -5195,10 +5214,9 @@
"x-go-package": "gitea.dev/modules/structs"
},
"DeployKey": {
"description": "DeployKey a deploy key",
"properties": {
"created_at": {
"description": "Created is the time when the deploy key was added",
"description": "Created is the time when the deploy-key was added",
"format": "date-time",
"type": "string",
"x-go-name": "Created"
@@ -5209,7 +5227,7 @@
"x-go-name": "Fingerprint"
},
"id": {
"description": "ID is the unique identifier for the deploy key",
"description": "ID is the unique identifier for the deploy-key",
"format": "int64",
"type": "integer",
"x-go-name": "ID"
@@ -5225,6 +5243,15 @@
"type": "integer",
"x-go-name": "KeyID"
},
"key_type": {
"description": "Type tells whether the key authenticates over SSH or with a token over HTTPS",
"enum": [
"ssh",
"token"
],
"type": "string",
"x-go-name": "KeyType"
},
"read_only": {
"description": "ReadOnly indicates if the key has read-only access",
"type": "boolean",
@@ -5238,8 +5265,13 @@
"type": "string",
"x-go-name": "Title"
},
"token": {
"description": "Token is the plaintext token of an HTTPS key, only returned when it is created",
"type": "string",
"x-go-name": "Token"
},
"url": {
"description": "URL is the API URL for this deploy key",
"description": "URL is the API URL for this deploy-key",
"format": "uri",
"type": "string",
"x-go-name": "URL"
@@ -26543,6 +26575,56 @@
]
}
},
"/repos/{owner}/{repo}/keys/tokens": {
"post": {
"operationId": "repoCreateDeployToken",
"parameters": [
{
"description": "owner of the repo",
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "name of the repo",
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateDeployKeyTokenOption"
}
}
},
"x-originalParamName": "body"
},
"responses": {
"201": {
"$ref": "#/components/responses/DeployKey"
},
"404": {
"$ref": "#/components/responses/notFound"
},
"422": {
"$ref": "#/components/responses/validationError"
}
},
"summary": "Add a deploy token to a repository, it authenticates git over HTTPS",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/keys/{id}": {
"delete": {
"operationId": "repoDeleteKey",

View File

@@ -14457,6 +14457,55 @@
}
}
},
"/repos/{owner}/{repo}/keys/tokens": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Add a deploy token to a repository, it authenticates git over HTTPS",
"operationId": "repoCreateDeployToken",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/CreateDeployKeyTokenOption"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/DeployKey"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/repos/{owner}/{repo}/keys/{id}": {
"get": {
"produces": [
@@ -26937,6 +26986,26 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"CreateDeployKeyTokenOption": {
"type": "object",
"required": [
"title"
],
"properties": {
"read_only": {
"description": "Describe if the token has only read access or read/write",
"type": "boolean",
"x-go-name": "ReadOnly"
},
"title": {
"description": "Title of the token to add",
"type": "string",
"uniqueItems": true,
"x-go-name": "Title"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"CreateEmailOption": {
"description": "CreateEmailOption options when creating email addresses",
"type": "object",
@@ -27190,7 +27259,6 @@
"x-go-package": "gitea.dev/modules/structs"
},
"CreateKeyOption": {
"description": "CreateKeyOption options when creating a key",
"type": "object",
"required": [
"title",
@@ -28186,11 +28254,10 @@
"x-go-package": "gitea.dev/modules/structs"
},
"DeployKey": {
"description": "DeployKey a deploy key",
"type": "object",
"properties": {
"created_at": {
"description": "Created is the time when the deploy key was added",
"description": "Created is the time when the deploy-key was added",
"type": "string",
"format": "date-time",
"x-go-name": "Created"
@@ -28201,7 +28268,7 @@
"x-go-name": "Fingerprint"
},
"id": {
"description": "ID is the unique identifier for the deploy key",
"description": "ID is the unique identifier for the deploy-key",
"type": "integer",
"format": "int64",
"x-go-name": "ID"
@@ -28217,6 +28284,15 @@
"format": "int64",
"x-go-name": "KeyID"
},
"key_type": {
"description": "Type tells whether the key authenticates over SSH or with a token over HTTPS",
"type": "string",
"enum": [
"ssh",
"token"
],
"x-go-name": "KeyType"
},
"read_only": {
"description": "ReadOnly indicates if the key has read-only access",
"type": "boolean",
@@ -28230,8 +28306,13 @@
"type": "string",
"x-go-name": "Title"
},
"token": {
"description": "Token is the plaintext token of an HTTPS key, only returned when it is created",
"type": "string",
"x-go-name": "Token"
},
"url": {
"description": "URL is the API URL for this deploy key",
"description": "URL is the API URL for this deploy-key",
"type": "string",
"x-go-name": "URL"
}

View File

@@ -11,6 +11,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
auth_model "gitea.dev/models/auth"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -66,7 +67,7 @@ func TestCreateReadOnlyDeployKey(t *testing.T) {
resp := MakeRequest(t, req, http.StatusCreated)
newDeployKey := DecodeJSON(t, resp, &api.DeployKey{})
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{
ID: newDeployKey.ID,
Name: rawKeyBody.Title,
Mode: perm.AccessModeRead,
@@ -103,7 +104,7 @@ func TestCreateReadWriteDeployKey(t *testing.T) {
resp := MakeRequest(t, req, http.StatusCreated)
newDeployKey := DecodeJSON(t, resp, &api.DeployKey{})
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{
ID: newDeployKey.ID,
Name: rawKeyBody.Title,
Mode: perm.AccessModeWrite,
@@ -204,3 +205,31 @@ func TestCreateUserKey(t *testing.T) {
fingerprintPublicKeys = DecodeJSON(t, resp, []api.PublicKey{})
assert.Empty(t, fingerprintPublicKeys)
}
func TestCreateDeployToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, repoOwner.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
req := NewRequestWithJSON(t, "POST", keysURL+"/tokens", api.CreateDeployKeyTokenOption{Title: "ci", ReadOnly: true}).
AddTokenAuth(token)
created := DecodeJSON(t, MakeRequest(t, req, http.StatusCreated), &api.DeployKey{})
assert.NotEmpty(t, created.Token)
assert.True(t, created.ReadOnly)
// a token is listed and deleted like a deploy key, but it is never readable again
resp := MakeRequest(t, NewRequest(t, "GET", keysURL).AddTokenAuth(token), http.StatusOK)
listed := DecodeJSON(t, resp, []api.DeployKey{})
assert.Len(t, listed, 1)
assert.NotContains(t, resp.Body.String(), created.Token)
assert.Equal(t, created.Fingerprint, listed[0].Fingerprint)
assert.Contains(t, created.Fingerprint, "********")
MakeRequest(t, NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", keysURL, created.ID)).AddTokenAuth(token), http.StatusNoContent)
resp = MakeRequest(t, NewRequest(t, "GET", keysURL).AddTokenAuth(token), http.StatusOK)
assert.Empty(t, DecodeJSON(t, resp, []api.DeployKey{}))
}

View File

@@ -8,8 +8,9 @@ import (
"net/url"
"testing"
asymkey_model "gitea.dev/models/asymkey"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
"gitea.dev/models/user"
"gitea.dev/modules/private"
"github.com/stretchr/testify/assert"
@@ -27,7 +28,7 @@ func TestAPIPrivateNoServ(t *testing.T) {
assert.Equal(t, "user2@localhost", key.Name)
keyContent := "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"
deployKey, err := asymkey_model.AddDeployKey(ctx, 1, "test-deploy", keyContent, perm.AccessModeRead)
deployKey, err := deploykey_model.AddDeployKeySSH(ctx, 1, "test-deploy", keyContent, perm.AccessModeRead)
assert.NoError(t, err)
key, user, err = private.ServNoCommand(ctx, deployKey.KeyID)
@@ -47,9 +48,8 @@ func TestAPIPrivateServ(t *testing.T) {
results, extra := private.ServCommand(ctx, 1, "user2", "repo1", perm.AccessModeWrite, "git-upload-pack", "")
assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki)
assert.Zero(t, results.DeployKeyID)
assert.Equal(t, int64(1), results.KeyID)
assert.Equal(t, "user2@localhost", results.KeyName)
assert.Empty(t, results.UserExtDoerData)
assert.Equal(t, int64(1), results.PublicKeyID)
assert.Equal(t, "user2", results.UserName)
assert.Equal(t, int64(2), results.UserID)
assert.Equal(t, "user2", results.OwnerName)
@@ -70,9 +70,8 @@ func TestAPIPrivateServ(t *testing.T) {
results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "")
assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki)
assert.Zero(t, results.DeployKeyID)
assert.Equal(t, int64(1), results.KeyID)
assert.Equal(t, "user2@localhost", results.KeyName)
assert.Empty(t, results.UserExtDoerData)
assert.Equal(t, int64(1), results.PublicKeyID)
assert.Equal(t, "user2", results.UserName)
assert.Equal(t, int64(2), results.UserID)
assert.Equal(t, "user15", results.OwnerName)
@@ -86,18 +85,18 @@ func TestAPIPrivateServ(t *testing.T) {
// Add reading deploy key
testContent := "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"
deployKey, err := asymkey_model.AddDeployKey(ctx, 19 /* repo id */, "test-deploy", testContent, perm.AccessModeRead)
deployKey, err := deploykey_model.AddDeployKeySSH(ctx, 19 /* repo id */, "test-deploy", testContent, perm.AccessModeRead)
assert.NoError(t, err)
// Can pull from repo we're a deploy-key for
deployKeyUser := user.NewDeployKeyUser()
results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "")
assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki)
assert.NotZero(t, results.DeployKeyID)
assert.Equal(t, deployKey.KeyID, results.KeyID)
assert.Equal(t, "test-deploy", results.KeyName)
assert.Equal(t, "user15", results.UserName)
assert.Equal(t, int64(15), results.UserID)
assert.NotEmpty(t, results.UserExtDoerData)
assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
assert.Equal(t, deployKeyUser.Name, results.UserName)
assert.Equal(t, deployKeyUser.ID, results.UserID)
assert.Equal(t, "user15", results.OwnerName)
assert.Equal(t, "big_test_private_1", results.RepoName)
assert.Equal(t, int64(19), results.RepoID)
@@ -119,7 +118,7 @@ func TestAPIPrivateServ(t *testing.T) {
// Add writing deploy key
testContent = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"
deployKey, err = asymkey_model.AddDeployKey(ctx, 20 /* repo id */, "test-deploy", testContent, perm.AccessModeWrite)
deployKey, err = deploykey_model.AddDeployKeySSH(ctx, 20 /* repo id */, "test-deploy", testContent, perm.AccessModeWrite)
assert.NoError(t, err)
// Cannot push to a private repo with reading key
@@ -131,11 +130,10 @@ func TestAPIPrivateServ(t *testing.T) {
results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "")
assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki)
assert.NotZero(t, results.DeployKeyID)
assert.Equal(t, deployKey.KeyID, results.KeyID)
assert.Equal(t, "test-deploy", results.KeyName)
assert.Equal(t, "user15", results.UserName)
assert.Equal(t, int64(15), results.UserID)
assert.NotEmpty(t, results.UserExtDoerData)
assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
assert.Equal(t, deployKeyUser.Name, results.UserName)
assert.Equal(t, deployKeyUser.ID, results.UserID)
assert.Equal(t, "user15", results.OwnerName)
assert.Equal(t, "big_test_private_2", results.RepoName)
assert.Equal(t, int64(20), results.RepoID)
@@ -144,11 +142,10 @@ func TestAPIPrivateServ(t *testing.T) {
results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeWrite, "git-upload-pack", "")
assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki)
assert.NotZero(t, results.DeployKeyID)
assert.Equal(t, deployKey.KeyID, results.KeyID)
assert.Equal(t, "test-deploy", results.KeyName)
assert.Equal(t, "user15", results.UserName)
assert.Equal(t, int64(15), results.UserID)
assert.NotEmpty(t, results.UserExtDoerData)
assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
assert.Equal(t, deployKeyUser.Name, results.UserName)
assert.Equal(t, deployKeyUser.ID, results.UserID)
assert.Equal(t, "user15", results.OwnerName)
assert.Equal(t, "big_test_private_2", results.RepoName)
assert.Equal(t, int64(20), results.RepoID)

View File

@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
lfs_module "gitea.dev/modules/lfs"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/require"
)
func TestDeployTokenGitHTTP(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// need to disable agit, otherwise the "write" permission check is skipped at pre-receive (git-receive-pack) step
defer test.MockVariableValue(&git.DefaultFeatures().SupportProcReceive, false)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
readKey, err := deploykey_model.AddDeployKeyToken(t.Context(), repo.ID, "read", perm.AccessModeRead)
require.NoError(t, err)
writeKey, err := deploykey_model.AddDeployKeyToken(t.Context(), repo.ID, "write", perm.AccessModeWrite)
require.NoError(t, err)
requestAs := func(t *testing.T, token, path string, expected int) {
MakeRequest(t, NewRequest(t, "GET", path).AddBasicAuth("deploy-token", token), expected)
}
t.Run("Clone", func(t *testing.T) {
requestAs(t, readKey.Token, "/"+repo.FullName()+"/info/refs?service=git-upload-pack", http.StatusOK)
})
t.Run("PushWithReadToken", func(t *testing.T) {
requestAs(t, readKey.Token, "/"+repo.FullName()+"/info/refs?service=git-receive-pack", http.StatusNotFound)
})
t.Run("PushWithWriteToken", func(t *testing.T) {
requestAs(t, writeKey.Token, "/"+repo.FullName()+"/info/refs?service=git-receive-pack", http.StatusOK)
})
t.Run("OtherRepo", func(t *testing.T) {
requestAs(t, readKey.Token, "/"+otherRepo.FullName()+"/info/refs?service=git-upload-pack", http.StatusNotFound)
})
t.Run("UnknownToken", func(t *testing.T) {
requestAs(t, deploykey_model.DeployTokenPrefix+"0123456789abcdef", "/"+repo.FullName()+"/info/refs?service=git-upload-pack", http.StatusUnauthorized)
})
t.Run("RejectedOutsideGitHTTP", func(t *testing.T) {
// the owner of the repo would be able to read it, the token must not act as that owner
requestAs(t, readKey.Token, "/api/v1/repos/"+repo.FullName(), http.StatusUnauthorized)
})
t.Run("LFS", func(t *testing.T) {
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
batchAs := func(t *testing.T, token, repoName, operation string, expected int) {
req := NewRequestWithJSON(t, "POST", "/"+repoName+"/info/lfs/objects/batch", lfs_module.BatchRequest{Operation: operation}).
AddBasicAuth("deploy-token", token).
SetHeader("Accept", lfs_module.AcceptHeader).
SetHeader("Content-Type", lfs_module.MediaType)
MakeRequest(t, req, expected)
}
batchAs(t, readKey.Token, repo.FullName(), "download", http.StatusOK)
batchAs(t, readKey.Token, repo.FullName(), "upload", http.StatusUnauthorized)
batchAs(t, writeKey.Token, repo.FullName(), "upload", http.StatusOK)
batchAs(t, readKey.Token, otherRepo.FullName(), "download", http.StatusUnauthorized)
})
}

View File

@@ -171,7 +171,7 @@ func doSSHLFSAccessTest(_ APITestContext, keyID int64) func(*testing.T) {
_, err := cmd.Output()
var errExit *exec.ExitError
require.ErrorAs(t, err, &errExit) // inaccessible, error
assert.Contains(t, string(errExit.Stderr), fmt.Sprintf(`User 2 with key %d:test-key has no "write" permission for user5/repo4`, keyID))
assert.Contains(t, string(errExit.Stderr), `has no "write" permission for user5/repo4`)
})
}
}

View File

@@ -17,6 +17,8 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// if it has "toggle" class, it toggles the panel
e.preventDefault();
const sel = el.getAttribute('data-panel')!;
const selHide = el.getAttribute('data-panel-hide');
if (selHide) hideElem(selHide);
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
for (const elem of elems) {
if (isElemVisible(elem as HTMLElement)) {

View File

@@ -38,7 +38,7 @@ function patchLabels(parent: ParentNode, containerSelector: string, labelSelecto
// link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
export function initAriaLabels(container: ParentNode) {
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select', 'data-field-patched');
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select, :scope > textarea', 'data-field-patched');
}
export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {