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-26 19:32:44 +00:00
committed by GitHub
co-authored by Claude Mythos silverwind bircni wxiaoguang
parent 3c4d5a6a5c
commit 646ea0f253
76 changed files with 1592 additions and 829 deletions
+27 -21
View File
@@ -186,23 +186,24 @@ Gitea or set your environment appropriately.`, "")
// the environment is set by serv command // the environment is set by serv command
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
username := os.Getenv(repo_module.EnvRepoUsername) ownerName := os.Getenv(repo_module.EnvRepoUsername)
reponame := os.Getenv(repo_module.EnvRepoName) repoName := os.Getenv(repo_module.EnvRepoName)
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 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{ hookOptions := private.HookOptions{
UserID: userID, IsWiki: isWiki,
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories), GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory), GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath), GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(), GitPushOptions: pushOptions(),
PullRequestID: prID,
DeployKeyID: deployKeyID, PullRequestID: prID,
ActionsTaskID: actionsTaskID,
IsWiki: isWiki, UserID: userID,
UserName: os.Getenv(repo_module.EnvPusherName),
UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
} }
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
@@ -257,7 +258,7 @@ Gitea or set your environment appropriately.`, "")
hookOptions.OldCommitIDs = oldCommitIDs hookOptions.OldCommitIDs = oldCommitIDs
hookOptions.NewCommitIDs = newCommitIDs hookOptions.NewCommitIDs = newCommitIDs
hookOptions.RefFullNames = refFullNames hookOptions.RefFullNames = refFullNames
extra := private.HookPreReceive(ctx, username, reponame, hookOptions) extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
if extra.HasError() { if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error) 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) 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() { if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error) 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) repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64) prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName)
hookOptions := private.HookOptions{ hookOptions := private.HookOptions{
UserName: pusherName, IsWiki: isWiki,
UserID: pusherID,
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories), GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory), GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath), GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(), GitPushOptions: pushOptions(),
PullRequestID: prID,
PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)), PullRequestID: prID,
IsWiki: isWiki, 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) oldCommitIDs := make([]string, 0, hookBatchSize)
@@ -481,7 +485,6 @@ Gitea or set your environment appropriately.`, "")
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki)) isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
repoName := os.Getenv(repo_module.EnvRepoName) repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64) pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
pusherName := os.Getenv(repo_module.EnvPusherName)
// 1. Version and features negotiation. // 1. Version and features negotiation.
// S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n) // 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: ... ...
// S: flush-pkt // S: flush-pkt
hookOptions := private.HookOptions{ hookOptions := private.HookOptions{
UserName: pusherName, IsWiki: isWiki,
UserID: pusherID,
GitPushOptions: make(map[string]string), 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.OldCommitIDs = make([]string, 0, hookBatchSize)
hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize) hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize)
+14 -9
View File
@@ -256,7 +256,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
if results.IsWiki { if results.IsWiki {
return fail(ctx, "LFS Transfer is not supported for wikis", "") 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 { if err != nil {
return err 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)) 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 { if err != nil {
return err 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, os.Environ()...)
command.Env = append(command.Env, command.Env = append(command.Env,
repo_module.EnvRepoIsWiki+"="+strconv.FormatBool(results.IsWiki), repo_module.EnvRepoIsWiki+"="+strconv.FormatBool(results.IsWiki),
repo_module.EnvRepoName+"="+results.RepoName,
repo_module.EnvRepoUsername+"="+results.OwnerName, 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.EnvPusherName+"="+results.UserName,
repo_module.EnvPusherEmail+"="+results.UserEmail, repo_module.EnvPusherEmail+"="+results.UserEmail,
repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10), repo_module.EnvPusherExtDoerData+"="+results.UserExtDoerData,
repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
repo_module.EnvPRID+"="+strconv.Itoa(0), 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, repo_module.EnvAppURL+"="+setting.AppURL,
) )
// to avoid breaking, here only use the minimal environment variables for the "gitea serv" command. // 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. // Update user key activity.
if results.KeyID > 0 { if results.PublicKeyID > 0 {
if err = private.UpdatePublicKeyInRepo(ctx, results.KeyID, results.RepoID); err != nil { if err = private.UpdatePublicKeyInRepo(ctx, results.PublicKeyID, results.RepoID); err != nil {
return fail(ctx, "Failed to update public key", "UpdatePublicKeyInRepo: %v", err) return fail(ctx, "Failed to update public key", "UpdatePublicKeyInRepo: %v", err)
} }
} }
+1
View File
@@ -425,6 +425,7 @@ func prepareMigrationTasks() []*migration {
newMigration(349, "Expand action_schedule content column", v28.ExpandActionScheduleContent), newMigration(349, "Expand action_schedule content column", v28.ExpandActionScheduleContent),
newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease), newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease),
newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer), newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer),
newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey),
} }
return preparedMigrations return preparedMigrations
} }
+38
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
}
+1 -8
View File
@@ -27,14 +27,7 @@ func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error
return err return err
} }
for _, attempt := range attempts { for _, attempt := range attempts {
if attempt.TriggerUserID == user_model.ActionsUserID { attempt.TriggerUser = user_model.GetPossibleUserFromMap(attempt.TriggerUserID, users)
attempt.TriggerUser = user_model.NewActionsUser()
} else {
attempt.TriggerUser = users[attempt.TriggerUserID]
if attempt.TriggerUser == nil {
attempt.TriggerUser = user_model.NewGhostUser()
}
}
} }
return nil return nil
} }
-61
View File
@@ -215,67 +215,6 @@ func (err ErrKeyAccessDenied) Unwrap() error {
return util.ErrPermissionDenied 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. // ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error.
type ErrSSHInvalidTokenSignature struct { type ErrSSHInvalidTokenSignature struct {
Wrapped error Wrapped error
+30
View File
@@ -89,6 +89,36 @@ func addPublicKey(ctx context.Context, key *PublicKey) (err error) {
return appendAuthorizedKeysToFile(key) 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. // 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) { func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) {
log.Trace(content) log.Trace(content)
-175
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
}
+99
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
}
+75
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})
}
+76
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
}
+52
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
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
}
+14
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
}
+14
View File
@@ -748,4 +748,18 @@
config: "{}" config: "{}"
created_unix: 946684810 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 # DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly
+29 -1
View File
@@ -13,6 +13,7 @@ import (
actions_model "gitea.dev/models/actions" actions_model "gitea.dev/models/actions"
"gitea.dev/models/db" "gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/organization" "gitea.dev/models/organization"
perm_model "gitea.dev/models/perm" perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
@@ -383,12 +384,39 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
return perm, nil 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, // 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) { func GetDoerRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (Permission, error) {
if taskID, ok := user_model.GetActionsUserTaskID(user); ok { if taskID, ok := user_model.GetActionsUserTaskID(user); ok {
return GetActionsUserRepoPermission(ctx, repo, user, taskID) return GetActionsUserRepoPermission(ctx, repo, user, taskID)
} }
if keyID, ok := user_model.GetDeployKeyUserDeployKeyID(user); ok {
return getDeployKeyRepoPermission(ctx, repo, keyID)
}
return GetIndividualUserRepoPermission(ctx, repo, user) return GetIndividualUserRepoPermission(ctx, repo, user)
} }
+1 -3
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 // 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 { 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 if u.ID <= 0 {
// which comes from git configure information
if u.IsGhost() || u.IsGiteaActions() || u.ID <= 0 {
return avatars.DefaultAvatarLink() return avatars.DefaultAvatarLink()
} }
+21 -11
View File
@@ -159,6 +159,11 @@ type User struct {
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"` Theme string `xorm:"NOT NULL DEFAULT ''"`
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"` 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 // 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 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 { 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 // 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. // IsMailable checks if a user is eligible to receive emails.
// System users like Ghost and Gitea Actions are excluded.
func (u *User) IsMailable() bool { 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, // IsUserExist checks if given username exist,
@@ -551,10 +555,11 @@ type globalVarsStruct struct {
emailToReplacer *strings.Replacer emailToReplacer *strings.Replacer
emailRegexp *regexp.Regexp emailRegexp *regexp.Regexp
systemUserNewFuncs map[int64]func() *User systemUserNewFuncs map[int64]func() *User
systemUserNameIdMap map[string]int64
} }
var globalVars = sync.OnceValue(func() *globalVarsStruct { var globalVars = sync.OnceValue(func() *globalVarsStruct {
return &globalVarsStruct{ ret := &globalVarsStruct{
// Note: The set of characters here can safely expand without a breaking change, // 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 // but characters removed from this set can cause user account linking to break
customCharsReplacement: strings.NewReplacer("Æ", "AE"), 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])?)*$"), 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. // 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 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) { func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
if id < 0 { if id < 0 {
if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok { if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok {
+46
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
}
+9 -12
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 { func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
switch userID { if userID == 0 {
case GhostUserID:
return NewGhostUser()
case ActionsUserID:
return NewActionsUser()
case 0:
return nil 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
} }
+63 -36
View File
@@ -4,7 +4,7 @@
package user package user
import ( import (
"strconv" "context"
"strings" "strings"
"gitea.dev/modules/structs" "gitea.dev/modules/structs"
@@ -32,59 +32,86 @@ func (u *User) IsGhost() bool {
return u.ID == GhostUserID && u.Name == GhostUserName 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 ( const (
ActionsUserID int64 = -2 ActionsUserID int64 = -2
ActionsUserName = "gitea-actions" DeployKeyUserID int64 = -3
ActionsUserEmail = "teabot@gitea.io"
) )
// NewActionsUser creates and returns a fake user for running the actions. // NewActionsUser creates and returns a fake user for running the actions.
func NewActionsUser() *User { func NewActionsUser() *User {
return &User{ return newSystemUser(ActionsUserID, "gitea-actions", "Gitea Actions")
ID: ActionsUserID, }
Name: ActionsUserName,
LowerName: ActionsUserName, func GetActionsUserTaskID(u *User) (int64, bool) {
IsActive: true, if u == nil || u.ExtDoerData == nil || u.ID != ActionsUserID {
FullName: "Gitea Actions", return 0, false
Email: ActionsUserEmail,
KeepEmailPrivate: true,
LoginName: ActionsUserName,
Type: UserTypeBot,
Visibility: structs.VisibleTypePublic,
} }
extData := u.ExtDoerData.(*extDoerGiteaActions) //nolint:forcetypeassert // must be valid
return extData.TaskID, true
} }
func NewActionsUserWithTaskID(id int64) *User { func NewActionsUserWithTaskID(id int64) *User {
u := NewActionsUser() u := NewActionsUser()
// LoginName is for only internal usage in this case, so it can be moved to other fields in the future u.ExtDoerData = &extDoerGiteaActions{TaskID: id}
u.LoginSource = -1
u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10)
return u return u
} }
func GetActionsUserTaskID(u *User) (int64, bool) { func NewDeployKeyUser() *User {
if u == nil || u.ID != ActionsUserID { return newSystemUser(DeployKeyUserID, "(deploy-key)", "Deploy Key")
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 (u *User) IsGiteaActions() bool { func GetDeployKeyUserDeployKeyID(u *User) (int64, bool) {
return u != nil && u.ID == ActionsUserID // 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 { func GetSystemUserByName(name string) *User {
if strings.EqualFold(name, GhostUserName) { lowerName := strings.ToLower(name)
return NewGhostUser() uid := globalVars().systemUserNameIdMap[lowerName]
} if fn := globalVars().systemUserNewFuncs[uid]; fn != nil {
if strings.EqualFold(name, ActionsUserName) { return fn()
return NewActionsUser()
} }
return nil 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)
}
-1
View File
@@ -27,7 +27,6 @@ func TestSystemUser(t *testing.T) {
assert.Equal(t, int64(-2), uid) assert.Equal(t, int64(-2), uid)
assert.Equal(t, "gitea-actions", u.Name) assert.Equal(t, "gitea-actions", u.Name)
assert.Equal(t, "gitea-actions", u.LowerName) assert.Equal(t, "gitea-actions", u.LowerName)
assert.True(t, u.IsGiteaActions())
u = GetSystemUserByName("Gitea-actionS") u = GetSystemUserByName("Gitea-actionS")
require.NotNil(t, u) require.NotNil(t, u)
+13 -10
View File
@@ -24,20 +24,23 @@ const (
// HookOptions represents the options for the Hook calls // HookOptions represents the options for the Hook calls
type HookOptions struct { type HookOptions struct {
OldCommitIDs []string IsWiki bool
NewCommitIDs []string
RefFullNames []git.RefName OldCommitIDs []string
UserID int64 NewCommitIDs []string
UserName string RefFullNames []git.RefName
GitObjectDirectory string GitObjectDirectory string
GitAlternativeObjectDirectories string GitAlternativeObjectDirectories string
GitQuarantinePath string GitQuarantinePath string
GitPushOptions GitPushOptions GitPushOptions GitPushOptions
PullRequestID int64
PushTrigger repository.PushTrigger PullRequestID int64
DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user. PushTrigger repository.PushTrigger
IsWiki bool
ActionsTaskID int64 // if the pusher is an Actions user, the task ID UserID int64
UserName string
UserExtDoerData string
} }
// SSHLogOption ssh log options // SSHLogOption ssh log options
+12 -10
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 // ServCommandResults are the results of a call to the private route serv
type ServCommandResults struct { type ServCommandResults struct {
IsWiki bool IsWiki bool
DeployKeyID int64
KeyID int64 // public key OwnerName string
KeyName string // this field is ambiguous, it can be the name of DeployKey, or the name of the PublicKey RepoName string
UserName string RepoID int64
UserEmail string
UserID int64 PublicKeyID int64
OwnerName string
RepoName string UserName string
RepoID int64 UserEmail string
UserID int64
UserExtDoerData string
RepoStoragePath string RepoStoragePath string
} }
+19 -17
View File
@@ -16,21 +16,23 @@ import (
// env keys for git hooks need // env keys for git hooks need
const ( const (
EnvRepoName = "GITEA_REPO_NAME" EnvRepoName = "GITEA_REPO_NAME"
EnvRepoUsername = "GITEA_REPO_USER_NAME" EnvRepoUsername = "GITEA_REPO_USER_NAME" // owner name
EnvRepoID = "GITEA_REPO_ID" EnvRepoID = "GITEA_REPO_ID"
EnvRepoIsWiki = "GITEA_REPO_IS_WIKI" EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
EnvPusherName = "GITEA_PUSHER_NAME"
EnvPusherEmail = "GITEA_PUSHER_EMAIL" EnvKeyID = "GITEA_KEY_ID" // public key ID
EnvPusherID = "GITEA_PUSHER_ID"
EnvKeyID = "GITEA_KEY_ID" // public key ID EnvPusherName = "GITEA_PUSHER_NAME"
EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID" EnvPusherEmail = "GITEA_PUSHER_EMAIL"
EnvPRID = "GITEA_PR_ID" EnvPusherID = "GITEA_PUSHER_ID"
EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks EnvPusherExtDoerData = "GITEA_PUSHER_EXT_DOER_DATA"
EnvPushTrigger = "GITEA_PUSH_TRIGGER"
EnvIsInternal = "GITEA_INTERNAL_PUSH" EnvPRID = "GITEA_PR_ID"
EnvAppURL = "GITEA_ROOT_URL" EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID" EnvPushTrigger = "GITEA_PUSH_TRIGGER"
EnvIsInternal = "GITEA_INTERNAL_PUSH"
EnvAppURL = "GITEA_ROOT_URL"
) )
type PushTrigger string type PushTrigger string
@@ -68,8 +70,8 @@ func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository,
if !doer.KeepEmailPrivate { if !doer.KeepEmailPrivate {
env = append(env, EnvPusherEmail+"="+doer.Email) env = append(env, EnvPusherEmail+"="+doer.Email)
} }
if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser { if doer.ExtDoerData != nil {
env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10)) env = append(env, EnvPusherExtDoerData+"="+doer.ExtDoerData.EncodeToString())
} }
return env return env
} }
+10 -3
View File
@@ -146,8 +146,15 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co
} }
} }
type TestingT interface {
Cleanup(func())
Context() context.Context
}
// NewRequestContextForTest creates a new RequestContext for testing purposes // NewRequestContextForTest creates a new RequestContext for testing purposes
// It doesn't add the context to the process manager, nor do cleanup func NewRequestContextForTest(t TestingT) RequestContext {
func NewRequestContextForTest(parentCtx context.Context) RequestContext { store := &requestDataStore{values: make(map[any]any)}
return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}} ret := &requestContext{Context: t.Context(), RequestDataStore: store}
t.Cleanup(store.cleanUp)
return ret
} }
+18 -7
View File
@@ -7,30 +7,33 @@ import (
"time" "time"
) )
// DeployKey a deploy key
type DeployKey struct { 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"` 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 is the associated public key ID
KeyID int64 `json:"key_id"` KeyID int64 `json:"key_id"`
// Key contains the actual SSH key content // Key contains the actual SSH key content
Key string `json:"key"` 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"` URL string `json:"url"`
// Title is the human-readable name for the key // Title is the human-readable name for the key
Title string `json:"title"` Title string `json:"title"`
// Fingerprint is the key's fingerprint // Fingerprint is the key's fingerprint
Fingerprint string `json:"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 // 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"` Created time.Time `json:"created_at"`
// ReadOnly indicates if the key has read-only access // ReadOnly indicates if the key has read-only access
ReadOnly bool `json:"read_only"` 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"` Repository *Repository `json:"repository,omitempty"`
} }
// CreateKeyOption options when creating a key
type CreateKeyOption struct { type CreateKeyOption struct {
// Title of the key to add // Title of the key to add
// //
@@ -43,7 +46,15 @@ type CreateKeyOption struct {
// unique: true // unique: true
Key string `json:"key" binding:"Required"` Key string `json:"key" binding:"Required"`
// Describe if the key has only read access or read/write // Describe if the key has only read access or read/write
ReadOnly bool `json:"read_only"`
}
type CreateDeployKeyTokenOption struct {
// Title of the token to add
// //
// required: false // 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"` ReadOnly bool `json:"read_only"`
} }
@@ -15,7 +15,7 @@ import (
) )
func TestRenderTimelineEventComment(t *testing.T) { func TestRenderTimelineEventComment(t *testing.T) {
ctx := reqctx.NewRequestContextForTest(t.Context()) ctx := reqctx.NewRequestContextForTest(t)
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{}) ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
ut := &RenderUtils{ctx: ctx} ut := &RenderUtils{ctx: ctx}
var createdStr template.HTML = "(created-at)" var createdStr template.HTML = "(created-at)"
+1 -1
View File
@@ -62,7 +62,7 @@ func TestMain(m *testing.M) {
} }
func newTestRenderUtils(t *testing.T) *RenderUtils { func newTestRenderUtils(t *testing.T) *RenderUtils {
ctx := reqctx.NewRequestContextForTest(t.Context()) ctx := reqctx.NewRequestContextForTest(t)
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{}) ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
return NewRenderUtils(ctx) return NewRenderUtils(ctx)
} }
+9 -6
View File
@@ -2387,19 +2387,22 @@
"repo.settings.packagist_api_token": "API token", "repo.settings.packagist_api_token": "API token",
"repo.settings.packagist_package_url": "Packagist package URL", "repo.settings.packagist_package_url": "Packagist package URL",
"repo.settings.deploy_keys": "Deploy Keys", "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": "Enable Write Access",
"repo.settings.is_writable_info": "Allow this deploy key to <strong>push</strong> to the repository.", "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.no_deploy_keys": "There are no deploy keys yet.",
"repo.settings.title": "Title", "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_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.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_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.branches": "Branches",
"repo.settings.protected_branch": "Branch Protection", "repo.settings.protected_branch": "Branch Protection",
"repo.settings.protected_branch.save_rule": "Save Rule", "repo.settings.protected_branch.save_rule": "Save Rule",
+3 -8
View File
@@ -189,13 +189,7 @@ func repoAssignment() func(ctx *context.APIContext) {
repo.Owner = owner repo.Owner = owner
ctx.Repo.Repository = repo 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) needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -228,7 +222,7 @@ func doerNeedTwoFactorAuth(ctx gocontext.Context, doer *user_model.User) (bool,
if !setting.TwoFactorAuthEnforced { if !setting.TwoFactorAuthEnforced {
return false, nil 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 return false, nil
} }
has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, doer.ID) has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, doer.ID)
@@ -1448,6 +1442,7 @@ func Routes() *web.Router {
m.Group("/keys", func() { m.Group("/keys", func() {
m.Combo("").Get(repo.ListDeployKeys). m.Combo("").Get(repo.ListDeployKeys).
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey) Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
m.Post("/tokens", bind(api.CreateDeployKeyTokenOption{}), repo.CreateDeployToken)
m.Combo("/{id}").Get(repo.GetDeployKey). m.Combo("/{id}").Get(repo.GetDeployKey).
Delete(repo.DeleteDeployKey) Delete(repo.DeleteDeployKey)
}, reqToken(), reqAdmin()) }, reqToken(), reqAdmin())
+25
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)
}
}
+53 -8
View File
@@ -11,6 +11,7 @@ import (
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db" "gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm" "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access" access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
@@ -24,7 +25,7 @@ import (
) )
// appendPrivateInformation appends the owner and key type information to api.PublicKey // 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 apiKey.ReadOnly = key.Mode == perm.AccessModeRead
if repository.ID == key.RepoID { if repository.ID == key.RepoID {
apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode}) apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode})
@@ -78,14 +79,14 @@ func ListDeployKeys(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
opts := asymkey_model.ListDeployKeysOptions{ opts := deploykey_model.ListDeployKeysOptions{
ListOptions: utils.GetListOptions(ctx), ListOptions: utils.GetListOptions(ctx),
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
KeyID: ctx.FormInt64("key_id"), KeyID: ctx.FormInt64("key_id"),
Fingerprint: ctx.FormString("fingerprint"), 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 { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -133,7 +134,7 @@ func GetDeployKey(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$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 { if err != nil {
ctx.APIErrorAuto(err) ctx.APIErrorAuto(err)
return return
@@ -160,13 +161,13 @@ func HandleCheckKeyStringError(ctx *context.APIContext, err error) {
// HandleAddKeyError handle add key error // HandleAddKeyError handle add key error
func HandleAddKeyError(ctx *context.APIContext, err error) { func HandleAddKeyError(ctx *context.APIContext, err error) {
switch { switch {
case asymkey_model.IsErrDeployKeyAlreadyExist(err): case deploykey_model.IsErrDeployKeyAlreadyExist(err):
ctx.APIError(http.StatusUnprocessableEntity, "This key has already been added to this repository") ctx.APIError(http.StatusUnprocessableEntity, "This key has already been added to this repository")
case asymkey_model.IsErrKeyAlreadyExist(err): case asymkey_model.IsErrKeyAlreadyExist(err):
ctx.APIError(http.StatusUnprocessableEntity, "Key content has been used as non-deploy key") ctx.APIError(http.StatusUnprocessableEntity, "Key content has been used as non-deploy key")
case asymkey_model.IsErrKeyNameAlreadyUsed(err): case asymkey_model.IsErrKeyNameAlreadyUsed(err):
ctx.APIError(http.StatusUnprocessableEntity, "Key title has been used") 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") ctx.APIError(http.StatusUnprocessableEntity, "A key with the same name already exists")
default: default:
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -213,7 +214,7 @@ func CreateDeployKey(ctx *context.APIContext) {
} }
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite) 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 { if err != nil {
HandleAddKeyError(ctx, err) HandleAddKeyError(ctx, err)
return return
@@ -221,6 +222,49 @@ func CreateDeployKey(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key)) 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 // DeleteDeployKey delete deploy key for a repository
func DeleteDeployKey(ctx *context.APIContext) { func DeleteDeployKey(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey // swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey
@@ -251,7 +295,8 @@ func DeleteDeployKey(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$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) { if asymkey_model.IsErrKeyAccessDenied(err) {
ctx.APIError(http.StatusForbidden, "You do not have access to this key") ctx.APIError(http.StatusForbidden, "You do not have access to this key")
} else { } else {
+3
View File
@@ -53,6 +53,9 @@ type swaggerParameterBodies struct {
// in:body // in:body
CreateKeyOption api.CreateKeyOption CreateKeyOption api.CreateKeyOption
// in:body
CreateDeployKeyTokenOption api.CreateDeployKeyTokenOption
// in:body // in:body
RenameUserOption api.RenameUserOption RenameUserOption api.RenameUserOption
+8 -10
View File
@@ -58,16 +58,13 @@ func Search(ctx *context.APIContext) {
uid := ctx.FormInt64("uid") uid := ctx.FormInt64("uid")
var users []*user_model.User var users []*user_model.User
var maxResults int64 var maxResults int64
var err error if uid < 0 {
_, sysUser, _ := user_model.GetPossibleUserByID(ctx, uid)
switch uid { if sysUser != nil && sysUser.ID == uid {
case user_model.GhostUserID: maxResults = 1
maxResults = 1 users = []*user_model.User{sysUser}
users = []*user_model.User{user_model.NewGhostUser()} }
case user_model.ActionsUserID: } else {
maxResults = 1
users = []*user_model.User{user_model.NewActionsUser()}
default:
opts := user_model.SearchUserOptions{ opts := user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Keyword: ctx.FormTrim("q"), Keyword: ctx.FormTrim("q"),
@@ -77,6 +74,7 @@ func Search(ctx *context.APIContext) {
ListOptions: listOptions, ListOptions: listOptions,
} }
opts.ApplyPublicOnly(ctx.PublicOnly) opts.ApplyPublicOnly(ctx.PublicOnly)
var err error
users, maxResults, err = user_model.SearchUsers(ctx, opts) users, maxResults, err = user_model.SearchUsers(ctx, opts)
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{ ctx.JSON(http.StatusInternalServerError, map[string]any{
+2 -2
View File
@@ -21,7 +21,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
t.Run("HTML", func(t *testing.T) { t.Run("HTML", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}} 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)")) renderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
respContent := w.Body.String() respContent := w.Body.String()
assert.Contains(t, respContent, `class="page-content status-page-500"`) 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) { t.Run("Plain", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}} req := &http.Request{URL: &url.URL{}}
req = req.WithContext(reqctx.NewRequestContextForTest(t.Context())) req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderServiceUnavailable(w, req) renderServiceUnavailable(w, req)
assert.Equal(t, "Service Unavailable", w.Body.String()) assert.Equal(t, "Service Unavailable", w.Body.String())
}) })
+6 -35
View File
@@ -4,18 +4,13 @@
package private package private
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" 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/git"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/private" "gitea.dev/modules/private"
@@ -103,15 +98,11 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported") setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
return return
} }
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
ownerName := ctx.PathParam("owner")
repoName := ctx.PathParam("repo")
repo := loadRepository(ctx, ownerName, repoName)
if ctx.Written() {
return return
} }
// now, repo can't be nil
repo := ctx.Repo.Repository
// first, collect updates and sync branches // first, collect updates and sync branches
updates := hookPostReceiveCollectPushUpdates(opts, repo) updates := hookPostReceiveCollectPushUpdates(opts, repo)
if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) { if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) {
@@ -144,17 +135,7 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate) isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
// Handle Push Options // Handle Push Options
if isPrivate.Has() || isTemplate.Has() { if isPrivate.Has() || isTemplate.Has() {
pusher, err := loadContextCacheUser(ctx, opts.UserID) if !ctx.Repo.Permission.IsAdmin() {
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() {
ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied") ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied")
return false 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. // yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() { if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
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) log.Error("failed to update repo is_private: %v", err)
} }
} }
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() { if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
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) 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}) 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 // 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 { func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool {
if len(updates) == 0 { if len(updates) == 0 {
@@ -261,15 +238,9 @@ func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext,
return false 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 // 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 // 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 { if err != nil {
ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err) ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err)
return false return false
+2 -1
View File
@@ -25,13 +25,14 @@ func TestHandlePullRequestMerging(t *testing.T) {
assert.NoError(t, pr.LoadBaseRepo(t.Context())) assert.NoError(t, pr.LoadBaseRepo(t.Context()))
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 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) err = pull_model.ScheduleAutoMerge(t.Context(), user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
assert.NoError(t, err) assert.NoError(t, err)
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID}) autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
ctx, resp := contexttest.MockPrivateContext(t, "/") ctx, resp := contexttest.MockPrivateContext(t, "/")
ctx.Doer = user2
hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{ hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{
PullRequestID: pr.ID, PullRequestID: pr.ID,
UserID: 2, UserID: 2,
+18 -76
View File
@@ -8,11 +8,8 @@ import (
"net/http" "net/http"
"os" "os"
asymkey_model "gitea.dev/models/asymkey"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
"gitea.dev/models/unit" "gitea.dev/models/unit"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
@@ -27,29 +24,18 @@ import (
type preReceiveContext struct { type preReceiveContext struct {
*gitea_context.PrivateContext *gitea_context.PrivateContext
env []string
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
opts *private.HookOptions opts *private.HookOptions
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here // this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool canWriteCodeUnitCached *bool
canCreatePullRequest *bool
protectedTags []*git_model.ProtectedTag
} }
func (ctx *preReceiveContext) canWriteCodeUnit() bool { func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil { if ctx.canWriteCodeUnitCached == nil {
canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite ctx.canWriteCodeUnitCached = new(ctx.Repo.Permission.CanWrite(unit.TypeCode))
ctx.canWriteCodeUnitCached = &canWrite
} }
return *ctx.canWriteCodeUnitCached return *ctx.canWriteCodeUnitCached
} }
@@ -63,7 +49,7 @@ func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if !refFullName.IsBranch() { if !refFullName.IsBranch() {
return false 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 // 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 // CanCreatePullRequest returns true if pusher can create pull requests
func (ctx *preReceiveContext) CanCreatePullRequest() bool { func (ctx *preReceiveContext) CanCreatePullRequest() bool {
if !ctx.checkedCanCreatePullRequest { if ctx.canCreatePullRequest == nil {
ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests) ctx.canCreatePullRequest = new(ctx.Repo.Permission.CanRead(unit.TypePullRequests))
ctx.checkedCanCreatePullRequest = true
} }
return ctx.canCreatePullRequest return *ctx.canCreatePullRequest
} }
// AssertCreatePullRequest returns true if can create pull requests // 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 // HookPreReceive checks whether a individual commit is acceptable
func HookPreReceive(ctx *gitea_context.PrivateContext) { func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts := web.GetForm[*private.HookOptions](ctx) opts := web.GetForm[*private.HookOptions](ctx)
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
return
}
ourCtx := &preReceiveContext{ ourCtx := &preReceiveContext{
PrivateContext: ctx, PrivateContext: ctx,
@@ -109,10 +97,6 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts: opts, opts: opts,
} }
if !ourCtx.loadPusherAndPermission() {
return // if error occurs, loadPusherAndPermission had written the error response
}
// Iterate across the provided old commit IDs // Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs { for i := range opts.OldCommitIDs {
oldCommitID := opts.OldCommitIDs[i] 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) // 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
var canPush bool 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 // This flag is only ever true if protectBranch.CanForcePush is true
if isForcePush { if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys) canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
@@ -245,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
} }
} else { } else {
if isForcePush { if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user) canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
} else { } 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 // 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 // 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 { if err != nil {
ctx.PrivateInternalErrorf("Error calculating if allowed to merge: %v", err) ctx.PrivateInternalErrorf("Error calculating if allowed to merge: %v", err)
return 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 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 return
} }
@@ -337,14 +321,14 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
tagName := refFullName.TagName() tagName := refFullName.TagName()
if !ctx.gotProtectedTags { if ctx.protectedTags == nil {
var err error var err error
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID) ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.PrivateInternalErrorf("Unable to get protected tags: %v", err) ctx.PrivateInternalErrorf("Unable to get protected tags: %v", err)
return return
} }
ctx.gotProtectedTags = true ctx.protectedTags = util.SliceNilAsEmpty(ctx.protectedTags)
} }
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID) 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 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
}
+6 -11
View File
@@ -7,7 +7,6 @@ import (
"testing" "testing"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
"gitea.dev/modules/git" "gitea.dev/modules/git"
@@ -19,7 +18,7 @@ import (
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against // 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. // 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. // together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) { func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase()) 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)) 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. // 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, "/") mockCtx, _ := contexttest.MockPrivateContext(t, "/")
ctx := &preReceiveContext{ ctx := &preReceiveContext{PrivateContext: mockCtx}
PrivateContext: mockCtx, ctx.SetPathParam("owner", headRepo.OwnerName)
user: maintainer, ctx.SetPathParam("repo", headRepo.Name)
userPerm: headPerm, RepoAssignment(ctx.PrivateContext)
} loadContextDoerPermission(ctx.PrivateContext, baseRepo.OwnerID, "")
// The granted branch must be writable... // The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch"))) assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
+10 -1
View File
@@ -23,8 +23,17 @@ func HookProcReceive(ctx *gitea_context.PrivateContext) {
ctx.Status(http.StatusNotFound) ctx.Status(http.StatusNotFound)
return 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 err != nil {
if errors.Is(err, issues_model.ErrMustCollaborator) { if errors.Is(err, issues_model.ErrMustCollaborator) {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "You must be a collaborator to create pull request.") ctx.PrivateUserErrorf(http.StatusUnauthorized, "You must be a collaborator to create pull request.")
+1 -1
View File
@@ -80,7 +80,7 @@ func Routes() *web.Router {
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo) r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog) r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive) 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.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
r.Get("/serv/none/{keyid}", ServNoCommand) r.Get("/serv/none/{keyid}", ServNoCommand)
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand) r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
+18 -4
View File
@@ -4,7 +4,9 @@
package private package private
import ( import (
"gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/user"
"gitea.dev/modules/git" "gitea.dev/modules/git"
gitea_context "gitea.dev/services/context" 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) ctx.PrivateInternalErrorf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
return return
} }
ctx.Repo = &gitea_context.Repository{ ctx.Repo = &gitea_context.Repository{Repository: repo, GitRepo: gitRepo}
Repository: repo,
GitRepo: gitRepo,
}
} }
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository { 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 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
}
+4 -5
View File
@@ -7,7 +7,7 @@ import (
"net/http" "net/http"
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/modules/timeutil" deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/services/context" "gitea.dev/services/context"
) )
@@ -20,17 +20,16 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
return return
} }
deployKey, err := asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID) deployKey, err := deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
if err != nil { if err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) { if deploykey_model.IsErrDeployKeyNotExist(err) {
ctx.PlainText(http.StatusOK, "success") ctx.PlainText(http.StatusOK, "success")
return return
} }
ctx.PrivateInternalErrorf("%v", err) ctx.PrivateInternalErrorf("%v", err)
return return
} }
deployKey.UpdatedUnix = timeutil.TimeStampNow() if err = deploykey_model.UpdateDeployKeyLastUsed(ctx, deployKey.ID); err != nil {
if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil {
ctx.PrivateInternalErrorf("%v", err) ctx.PrivateInternalErrorf("%v", err)
return return
} }
+35 -54
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm" "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access" access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
@@ -73,9 +74,9 @@ func ServCommand(ctx *context.PrivateContext) {
// Set the basic parts of the results to return // Set the basic parts of the results to return
results := private.ServCommandResults{ results := private.ServCommandResults{
OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection" 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 RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
KeyID: keyID, PublicKeyID: keyID,
} }
repoLogName := reqOwnerName + "/" + reqRepoName repoLogName := reqOwnerName + "/" + reqRepoName
@@ -184,40 +185,25 @@ func ServCommand(ctx *context.PrivateContext) {
ctx.PrivateInternalErrorf("Unable to get key: %d, error: %v", keyID, err) ctx.PrivateInternalErrorf("Unable to get key: %d, error: %v", keyID, err)
return return
} }
results.KeyName = key.Name results.PublicKeyID = key.ID
results.KeyID = key.ID
results.UserID = key.OwnerID
// Deploy Keys have ownerID set to 0 therefore we can't use the owner var deployKey *deploykey_model.DeployKey
// 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 user *user_model.User var user *user_model.User
if key.Type == asymkey_model.KeyTypeDeploy { if key.Type == asymkey_model.KeyTypeDeploy {
if repo == nil { if repo == nil {
ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName) ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName)
return 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 err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) { 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) ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy-key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
return return
} }
ctx.PrivateInternalErrorf("Unable to get deploy for public (deploy) key %d for %s, error: %v", key.ID, repoLogName, err) ctx.PrivateInternalErrorf("Unable to get deploy for public (deploy) key %d for %s, error: %v", key.ID, repoLogName, err)
return return
} }
results.DeployKeyID = deployKey.ID user = user_model.NewDeployKeyUserWithKeyID(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
}
} else { } else {
// Get the user represented by the Key // Get the user represented by the Key
user, err = user_model.GetUserByID(ctx, key.OwnerID) 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) ctx.PrivateInternalErrorf("Unable to get key owner %d for public key %d:%s, error: %v", key.OwnerID, key.ID, key.Name, err)
return return
} }
if !user.IsActive || user.ProhibitLogin { if !user.IsActive || user.ProhibitLogin {
ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.") ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.")
return return
} }
}
results.UserName = user.Name results.UserID = user.ID
if !user.KeepEmailPrivate { results.UserName = user.Name
results.UserEmail = user.Email 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 // Don't allow pushing if the repo is archived
@@ -252,37 +241,29 @@ func ServCommand(ctx *context.PrivateContext) {
(mode > perm.AccessModeRead || (mode > perm.AccessModeRead ||
repo.IsPrivate || repo.IsPrivate ||
owner.Visibility.IsPrivate() || owner.Visibility.IsPrivate() ||
(user != nil && user.IsRestricted) || // user will be nil if the key is a deploy key user.IsRestricted ||
setting.Service.RequireSignInViewStrict) { setting.Service.RequireSignInViewStrict) {
if key.Type == asymkey_model.KeyTypeDeploy { // Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
if deployKey == nil || deployKey.Mode < mode { // AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
ctx.PrivateUserErrorf(http.StatusUnauthorized, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName) // The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
return // 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 {
} else { 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) userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
if err != nil { if err != nil {
ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err) ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
return return
} }
userMode := userPerm.UnitAccessMode(unitType) userMode := userPerm.UnitAccessMode(unitType)
if userMode < mode { 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) ctx.PrivateUserErrorf(http.StatusUnauthorized, "User key %d:%s has no %q permission for %s", key.ID, key.Name, modeString, repoLogName)
return return
}
} }
} }
// We already know we aren't using a deploy key
if repo == nil { if repo == nil {
if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
ctx.PrivateUserErrorf(http.StatusForbidden, "Push to create is not enabled for organizations.") ctx.PrivateUserErrorf(http.StatusForbidden, "Push to create is not enabled for organizations.")
+1 -1
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) req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
resp := httptest.NewRecorder() 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{ ctx.Repo.Repository = &repo_model.Repository{
OwnerName: "user1", OwnerName: "user1",
Name: "repo1", Name: "repo1",
+1 -2
View File
@@ -163,7 +163,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil 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) _, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if err == nil { 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 // 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 var environ []string
if !isPull { if !isPull {
// if not "pull", then must be "push", and doer must exist
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki) environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
} }
+49 -14
View File
@@ -9,7 +9,9 @@ import (
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db" "gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm" "gitea.dev/models/perm"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util" "gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey" asymkey_service "gitea.dev/services/asymkey"
@@ -17,23 +19,20 @@ import (
"gitea.dev/services/forms" "gitea.dev/services/forms"
) )
// DeployKeys render the deploy-keys list of a repository page
func DeployKeys(ctx *context.Context) { 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["PageIsSettingsKeys"] = true
ctx.Data["DisableSSH"] = setting.SSH.Disabled 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 { if err != nil {
ctx.ServerError("ListDeployKeys", err) ctx.ServerError("ListDeployKeys", err)
return return
} }
ctx.Data["RepoDeployKeys"] = keys ctx.Data["RepoDeployKeys"] = keys
ctx.HTML(http.StatusOK, tplDeployKeys) ctx.HTML(http.StatusOK, tplDeployKeys)
} }
// DeployKeysPost response for adding a deploy-key of a repository
func DeployKeysPost(ctx *context.Context) { func DeployKeysPost(ctx *context.Context) {
form := context.GetFetchActionForm[*forms.AddKeyForm](ctx) form := context.GetFetchActionForm[*forms.AddKeyForm](ctx)
if form == nil { if form == nil {
@@ -54,16 +53,14 @@ func DeployKeysPost(ctx *context.Context) {
} }
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead) 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 { if err != nil {
switch { switch {
case asymkey_model.IsErrDeployKeyAlreadyExist(err): case deploykey_model.IsErrDeployKeyAlreadyExist(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content") ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content")
case asymkey_model.IsErrKeyAlreadyExist(err): case asymkey_model.IsErrKeyAlreadyExist(err):
ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content") ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content")
case asymkey_model.IsErrKeyNameAlreadyUsed(err): case asymkey_model.IsErrKeyNameAlreadyUsed(err), deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title") ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
default: default:
ctx.ServerError("AddDeployKey", err) ctx.ServerError("AddDeployKey", err)
@@ -75,12 +72,50 @@ func DeployKeysPost(ctx *context.Context) {
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
} }
// DeleteDeployKey response for deleting a deploy-key
func DeleteDeployKey(ctx *context.Context) { 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) ctx.ServerError("DeleteDeployKey", err)
} else { return
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success")) }
if key != nil {
ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success", key.Name))
} }
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") 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")
}
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"net/url" "net/url"
"testing" "testing"
asymkey_model "gitea.dev/models/asymkey" deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/organization" "gitea.dev/models/organization"
"gitea.dev/models/perm" "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access" access_model "gitea.dev/models/perm/access"
@@ -38,7 +38,7 @@ func TestAddDeployKey(t *testing.T) {
contexttest.LoadRepo(t, ctx, 2) contexttest.LoadRepo(t, ctx, 2)
DeployKeysPost(ctx) DeployKeysPost(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) 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) { t.Run("ReadWrite", func(t *testing.T) {
const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n" const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n"
@@ -47,7 +47,7 @@ func TestAddDeployKey(t *testing.T) {
contexttest.LoadRepo(t, ctx, 2) contexttest.LoadRepo(t, ctx, 2)
DeployKeysPost(ctx) DeployKeysPost(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) 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})
}) })
} }
+12 -3
View File
@@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
type AuthMiddleware struct { type AuthMiddleware struct {
AllowOAuth2 types.PreMiddlewareProvider AllowOAuth2 types.PreMiddlewareProvider
AllowBasic types.PreMiddlewareProvider AllowBasic types.PreMiddlewareProvider
AllowDeployToken types.PreMiddlewareProvider
MiddlewareHandler func(*context.Context) MiddlewareHandler func(*context.Context)
} }
func newWebAuthMiddleware() *AuthMiddleware { func newWebAuthMiddleware() *AuthMiddleware {
type keyAllowOAuth2 struct{} type keyAllowOAuth2 struct{}
type keyAllowBasic struct{} type keyAllowBasic struct{}
type keyAllowDeployToken struct{}
webAuth := &AuthMiddleware{} webAuth := &AuthMiddleware{}
middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider { middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider {
@@ -114,11 +116,13 @@ func newWebAuthMiddleware() *AuthMiddleware {
webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true) webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true)
webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true) webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true)
webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true)
enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext()) enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext())
webAuth.MiddlewareHandler = func(ctx *context.Context) { webAuth.MiddlewareHandler = func(ctx *context.Context) {
allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true
allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true
allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true
group := auth_service.NewGroup() group := auth_service.NewGroup()
@@ -127,13 +131,16 @@ func newWebAuthMiddleware() *AuthMiddleware {
if allowOAuth2 { if allowOAuth2 {
group.Add(&auth_service.OAuth2{}) group.Add(&auth_service.OAuth2{})
} }
if allowDeployToken {
group.Add(&auth_service.DeployToken{}) // before Basic, which would try the token as a password
}
if allowBasic { if allowBasic {
group.Add(&auth_service.Basic{}) 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 // 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 // For example: accessing git via http, access rss feeds, downloading attachments, etc
isSessionless := allowOAuth2 || allowBasic isSessionless := allowOAuth2 || allowBasic || allowDeployToken
if setting.Service.EnableReverseProxyAuth { if setting.Service.EnableReverseProxyAuth {
// reverse-proxy should before Session, otherwise the header will be ignored if user has login // 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.Group("/keys", func() {
m.Combo("").Get(repo_setting.DeployKeys). m.Combo("").Get(repo_setting.DeployKeys).
Post(repo_setting.DeployKeysPost) Post(repo_setting.DeployKeysPost)
m.Post("/generate-token", repo_setting.DeployKeyGenerateToken)
m.Post("/regenerate-token", repo_setting.DeployKeyRegenerateToken)
m.Post("/delete", repo_setting.DeleteDeployKey) 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 // 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 // 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, // Some users want to use "web-based git client" to access Gitea's repositories,
// so the CORS handler and OPTIONS method are used. // so the CORS handler and OPTIONS method are used.
// pattern: "/{username}/{reponame}/{git-paths}": git http support // 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.Group("/notifications", func() {
m.Get("", user.Notifications) m.Get("", user.Notifications)
+1 -1
View File
@@ -121,7 +121,7 @@ func (input *notifyInput) Notify(ctx context.Context) {
func notify(ctx context.Context, input *notifyInput) error { func notify(ctx context.Context, input *notifyInput) error {
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch 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: // avoiding triggering cyclically, for example:
// a comment of an issue will trigger the runner to add a new comment as reply, // 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. // and the new comment will trigger the runner again.
+13 -6
View File
@@ -60,8 +60,18 @@ func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string)
return "", "", util.NewNotExistErrorf("base branch does not exist") 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 // 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)) results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush) forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
topicBranch := opts.GitPushOptions["topic"] topicBranch := opts.GitPushOptions["topic"]
@@ -72,12 +82,9 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
description := parseAgitPushOptionValue(opts.GitPushOptions["description"]) description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName) objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
userName := strings.ToLower(opts.UserName)
pusher, err := user_model.GetUserByID(ctx, opts.UserID) pusher := opts.Doer
if err != nil { userName := strings.ToLower(pusher.Name)
return nil, fmt.Errorf("failed to get user. Error: %w", err)
}
for i := range opts.OldCommitIDs { for i := range opts.OldCommitIDs {
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() { if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
+23 -17
View File
@@ -9,12 +9,13 @@ import (
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db" "gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
) )
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside // DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) { 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 { if err != nil {
return 0, fmt.Errorf("listDeployKeys: %w", err) 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 // deleteDeployKeyFromDB delete deploy keys from database
func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) error { func deleteDeployKeyFromDB(ctx context.Context, key *deploykey_model.DeployKey) error {
if _, err := db.DeleteByID[asymkey_model.DeployKey](ctx, key.ID); err != nil { if _, err := db.DeleteByID[deploykey_model.DeployKey](ctx, key.ID); err != nil {
return fmt.Errorf("delete deploy key [%d]: %w", key.ID, err) 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. // 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 { if err != nil {
return err return err
} else if !has { } else if !has {
@@ -46,21 +51,22 @@ func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) er
return nil return nil
} }
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed. // DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed,
// Permissions check should be done outside. // and returns the key it deleted. Permissions check should be done outside.
func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) error { func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) (*deploykey_model.DeployKey, error) {
if err := db.WithTx(ctx, func(ctx context.Context) error { deleted, err := db.WithTx2(ctx, func(ctx context.Context) (*deploykey_model.DeployKey, error) {
key, err := asymkey_model.GetDeployKeyByID(ctx, repo.ID, id) key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, id)
if err != nil { if err != nil {
if asymkey_model.IsErrDeployKeyNotExist(err) { return nil, err
return nil
}
return fmt.Errorf("GetDeployKeyByID: %w", err)
} }
return deleteDeployKeyFromDB(ctx, key) return key, deleteDeployKeyFromDB(ctx, key)
}); err != nil { })
return err 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)
} }
+4 -3
View File
@@ -29,6 +29,7 @@ const (
AccessTokenMethodName = "access_token" AccessTokenMethodName = "access_token"
OAuth2TokenMethodName = "oauth2_token" OAuth2TokenMethodName = "oauth2_token"
ActionTokenMethodName = "action_token" ActionTokenMethodName = "action_token"
DeployTokenMethodName = "deploy_token"
) )
// Basic implements the Auth interface and authenticates requests (API requests // Basic implements the Auth interface and authenticates requests (API requests
@@ -41,7 +42,7 @@ func (b *Basic) Name() string {
return BasicMethodName 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") authHeader := req.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
return ret 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 uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// Check if username or password is a token // 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 // Assume username is token
authToken := uname authToken := uname
if !isUsernameToken { if !isUsernameToken {
@@ -122,7 +123,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
// name/token on successful validation. // name/token on successful validation.
// Returns nil if header is empty or validation fails. // 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) { 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 authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
if authToken == "" && uname == "" { if authToken == "" && uname == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable return nil, nil //nolint:nilnil // the auth method is not applicable
+47
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
}
+2 -2
View File
@@ -225,11 +225,11 @@ func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
return b 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 { if !setting.IsInTesting {
panic("This function is only for testing") panic("This function is only for testing")
} }
ctx := reqctx.NewRequestContextForTest(req.Context()) ctx := reqctx.NewRequestContextForTest(t)
*req = *req.WithContext(ctx) *req = *req.WithContext(ctx)
return NewBaseContext(resp, req) return NewBaseContext(resp, req)
} }
+2 -2
View File
@@ -29,7 +29,7 @@ func TestRedirect(t *testing.T) {
} }
for _, c := range cases { for _, c := range cases {
resp := httptest.NewRecorder() 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()) resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String())
b.Redirect(c.url) b.Redirect(c.url)
has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy" 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) req, _ = http.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
req.Header.Add("X-Gitea-Fetch-Action", "1") req.Header.Add("X-Gitea-Fetch-Action", "1")
b := NewBaseContextForTest(resp, req) b := NewBaseContextForTest(t, resp, req)
b.Redirect("/other") b.Redirect("/other")
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json") assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String()) assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
+2 -2
View File
@@ -42,7 +42,7 @@ func TestRedirectToCurrentSite(t *testing.T) {
t.Run(c.location, func(t *testing.T) { t.Run(c.location, func(t *testing.T) {
req := &http.Request{URL: &url.URL{Path: "/"}} req := &http.Request{URL: &url.URL{Path: "/"}}
resp := httptest.NewRecorder() resp := httptest.NewRecorder()
base := NewBaseContextForTest(resp, req) base := NewBaseContextForTest(t, resp, req)
ctx := NewWebContext(base, nil, nil) ctx := NewWebContext(base, nil, nil)
ctx.RedirectToCurrentSite(c.location) ctx.RedirectToCurrentSite(c.location)
redirect := test.RedirectURL(resp) redirect := test.RedirectURL(resp)
@@ -58,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)() defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil) 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", string(tmplCtx.AppFullLink()))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo"))) assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
+2
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"time" "time"
user_model "gitea.dev/models/user"
"gitea.dev/modules/graceful" "gitea.dev/modules/graceful"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/private" "gitea.dev/modules/private"
@@ -23,6 +24,7 @@ type PrivateContext struct {
*Base *Base
Override context.Context Override context.Context
Doer *user_model.User
Repo *Repository Repo *Repository
} }
+1 -1
View File
@@ -42,7 +42,7 @@ func mockRequest(t *testing.T, reqPath string) *http.Request {
requestURL, err := url.Parse(path) requestURL, err := url.Parse(path)
assert.NoError(t, err) assert.NoError(t, err)
req := &http.Request{Method: method, Host: requestURL.Host, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}} 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 return req
} }
+13 -10
View File
@@ -20,6 +20,7 @@ import (
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth" "gitea.dev/models/auth"
"gitea.dev/models/db" "gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
git_model "gitea.dev/models/git" git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization" "gitea.dev/models/organization"
@@ -846,19 +847,21 @@ func ToGitHook(h *git.Hook) *api.GitHook {
} }
} }
// ToDeployKey convert asymkey_model.DeployKey to api.DeployKey // ToDeployKey convert deploykey_model.DeployKey to api.DeployKey
func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *asymkey_model.DeployKey) *api.DeployKey { func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *deploykey_model.DeployKey) *api.DeployKey {
k := &api.DeployKey{ k := &api.DeployKey{
ID: deployKey.ID, ID: deployKey.ID,
KeyID: deployKey.KeyID, KeyType: util.Iif(deployKey.KeyType == deploykey_model.KeyTypeSSH, "ssh", "token"),
URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID), KeyID: deployKey.KeyID,
Title: deployKey.Name, Token: deployKey.Token,
Created: deployKey.CreatedUnix.AsTime(), URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
ReadOnly: deployKey.Mode == perm.AccessModeRead, // All deploy keys are read-only. 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.Key = deployKey.PublicKey.Content
k.Fingerprint = deployKey.PublicKey.Fingerprint
} }
return k return k
} }
+7
View File
@@ -576,3 +576,10 @@ type SaveTopicForm struct {
middleware.FormDefaultValidator middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"` 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
}
+14 -20
View File
@@ -49,16 +49,18 @@ type requestContext struct {
// Claims is a JWT Token Claims // Claims is a JWT Token Claims
type Claims struct { type Claims struct {
RepoID int64 RepoID int64
Op string Op string
UserID int64 UserID int64
UserExtDoerData string
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type AuthTokenOptions struct { type AuthTokenOptions struct {
Op string Op string
UserID int64 UserID int64
RepoID int64 UserExtDoerData string
RepoID int64
} }
func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) { func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
@@ -68,9 +70,10 @@ func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)), ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
}, },
RepoID: opts.RepoID, RepoID: opts.RepoID,
Op: opts.Op, Op: opts.Op,
UserID: opts.UserID, UserID: opts.UserID,
UserExtDoerData: opts.UserExtDoerData,
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -544,15 +547,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
accessMode = perm_model.AccessModeWrite 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 // 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) perm, err := access_model.GetDoerRepoPermission(ctx, repository, ctx.Doer)
if err != nil { 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") 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 { 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 return nil, err
} }
if !u.IsActive || u.ProhibitLogin { if !u.IsActive || u.ProhibitLogin {
+20
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"gitea.dev/models/db" "gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
perm_model "gitea.dev/models/perm" perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
@@ -101,4 +102,23 @@ func TestAuthenticate(t *testing.T) {
err := handleLFSTokenTestPerm("upload", 2, repo1, perm_model.AccessModeWrite) err := handleLFSTokenTestPerm("upload", 2, repo1, perm_model.AccessModeWrite)
assert.NoError(t, err) 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")
})
} }
+1 -1
View File
@@ -38,7 +38,7 @@ func TestRenderHelperMention(t *testing.T) {
// when using web context, use user.IsUserVisibleToViewer to check // when using web context, use user.IsUserVisibleToViewer to check
req, err := http.NewRequest(http.MethodGet, "/", nil) req, err := http.NewRequest(http.MethodGet, "/", nil)
assert.NoError(t, err) 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) giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic)) assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
+4 -14
View File
@@ -96,13 +96,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
if opts.RefFullName.IsTag() { if opts.RefFullName.IsTag() {
if pusher == nil || pusher.ID != opts.PusherID { if pusher == nil || pusher.ID != opts.PusherID {
if opts.PusherID == user_model.ActionsUserID { if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
pusher = user_model.NewActionsUser() return err
} else {
var err error
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
return err
}
} }
} }
tagName := opts.RefFullName.TagName() tagName := opts.RefFullName.TagName()
@@ -143,13 +138,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
} }
} else if opts.RefFullName.IsBranch() { } else if opts.RefFullName.IsBranch() {
if pusher == nil || pusher.ID != opts.PusherID { if pusher == nil || pusher.ID != opts.PusherID {
if opts.PusherID == user_model.ActionsUserID { if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
pusher = user_model.NewActionsUser() return err
} else {
var err error
if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
return err
}
} }
} }
@@ -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}}
+62 -79
View File
@@ -1,92 +1,75 @@
{{template "repo/settings/layout_head" (dict "pageClass" "repository settings")}} {{template "repo/settings/layout_head" (dict "pageClass" "repository settings")}}
<div class="repo-setting-content"> <div class="repo-setting-content">
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.deploy_keys"}} {{ctx.Locale.Tr "repo.settings.deploy_keys"}}
<div class="ui right"> <div class="ui right">
{{if not .DisableSSH}} {{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}} {{else}}
<button class="ui primary tiny button disabled">{{ctx.Locale.Tr "settings.ssh_disabled"}}</button> <button class="ui primary compact 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"}}
{{end}} {{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> </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>
<div class="ui small modal" id="repo-deploy-key-delete-modal"> <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> <div class="content"><p>{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}</p></div>
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</div> </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" .}} {{template "repo/settings/layout_footer" .}}
+87 -5
View File
@@ -3973,6 +3973,26 @@
"type": "object", "type": "object",
"x-go-package": "gitea.dev/modules/structs" "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": { "CreateEmailOption": {
"description": "CreateEmailOption options when creating email addresses", "description": "CreateEmailOption options when creating email addresses",
"properties": { "properties": {
@@ -4227,7 +4247,6 @@
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
}, },
"CreateKeyOption": { "CreateKeyOption": {
"description": "CreateKeyOption options when creating a key",
"properties": { "properties": {
"key": { "key": {
"description": "An armored SSH key to add", "description": "An armored SSH key to add",
@@ -5195,10 +5214,9 @@
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
}, },
"DeployKey": { "DeployKey": {
"description": "DeployKey a deploy key",
"properties": { "properties": {
"created_at": { "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", "format": "date-time",
"type": "string", "type": "string",
"x-go-name": "Created" "x-go-name": "Created"
@@ -5209,7 +5227,7 @@
"x-go-name": "Fingerprint" "x-go-name": "Fingerprint"
}, },
"id": { "id": {
"description": "ID is the unique identifier for the deploy key", "description": "ID is the unique identifier for the deploy-key",
"format": "int64", "format": "int64",
"type": "integer", "type": "integer",
"x-go-name": "ID" "x-go-name": "ID"
@@ -5225,6 +5243,15 @@
"type": "integer", "type": "integer",
"x-go-name": "KeyID" "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": { "read_only": {
"description": "ReadOnly indicates if the key has read-only access", "description": "ReadOnly indicates if the key has read-only access",
"type": "boolean", "type": "boolean",
@@ -5238,8 +5265,13 @@
"type": "string", "type": "string",
"x-go-name": "Title" "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": { "url": {
"description": "URL is the API URL for this deploy key", "description": "URL is the API URL for this deploy-key",
"format": "uri", "format": "uri",
"type": "string", "type": "string",
"x-go-name": "URL" "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}": { "/repos/{owner}/{repo}/keys/{id}": {
"delete": { "delete": {
"operationId": "repoDeleteKey", "operationId": "repoDeleteKey",
+86 -5
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}": { "/repos/{owner}/{repo}/keys/{id}": {
"get": { "get": {
"produces": [ "produces": [
@@ -26937,6 +26986,26 @@
}, },
"x-go-package": "gitea.dev/modules/structs" "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": { "CreateEmailOption": {
"description": "CreateEmailOption options when creating email addresses", "description": "CreateEmailOption options when creating email addresses",
"type": "object", "type": "object",
@@ -27190,7 +27259,6 @@
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
}, },
"CreateKeyOption": { "CreateKeyOption": {
"description": "CreateKeyOption options when creating a key",
"type": "object", "type": "object",
"required": [ "required": [
"title", "title",
@@ -28186,11 +28254,10 @@
"x-go-package": "gitea.dev/modules/structs" "x-go-package": "gitea.dev/modules/structs"
}, },
"DeployKey": { "DeployKey": {
"description": "DeployKey a deploy key",
"type": "object", "type": "object",
"properties": { "properties": {
"created_at": { "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", "type": "string",
"format": "date-time", "format": "date-time",
"x-go-name": "Created" "x-go-name": "Created"
@@ -28201,7 +28268,7 @@
"x-go-name": "Fingerprint" "x-go-name": "Fingerprint"
}, },
"id": { "id": {
"description": "ID is the unique identifier for the deploy key", "description": "ID is the unique identifier for the deploy-key",
"type": "integer", "type": "integer",
"format": "int64", "format": "int64",
"x-go-name": "ID" "x-go-name": "ID"
@@ -28217,6 +28284,15 @@
"format": "int64", "format": "int64",
"x-go-name": "KeyID" "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": { "read_only": {
"description": "ReadOnly indicates if the key has read-only access", "description": "ReadOnly indicates if the key has read-only access",
"type": "boolean", "type": "boolean",
@@ -28230,8 +28306,13 @@
"type": "string", "type": "string",
"x-go-name": "Title" "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": { "url": {
"description": "URL is the API URL for this deploy key", "description": "URL is the API URL for this deploy-key",
"type": "string", "type": "string",
"x-go-name": "URL" "x-go-name": "URL"
} }
+31 -2
View File
@@ -11,6 +11,7 @@ import (
asymkey_model "gitea.dev/models/asymkey" asymkey_model "gitea.dev/models/asymkey"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm" "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
@@ -66,7 +67,7 @@ func TestCreateReadOnlyDeployKey(t *testing.T) {
resp := MakeRequest(t, req, http.StatusCreated) resp := MakeRequest(t, req, http.StatusCreated)
newDeployKey := DecodeJSON(t, resp, &api.DeployKey{}) newDeployKey := DecodeJSON(t, resp, &api.DeployKey{})
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{
ID: newDeployKey.ID, ID: newDeployKey.ID,
Name: rawKeyBody.Title, Name: rawKeyBody.Title,
Mode: perm.AccessModeRead, Mode: perm.AccessModeRead,
@@ -103,7 +104,7 @@ func TestCreateReadWriteDeployKey(t *testing.T) {
resp := MakeRequest(t, req, http.StatusCreated) resp := MakeRequest(t, req, http.StatusCreated)
newDeployKey := DecodeJSON(t, resp, &api.DeployKey{}) newDeployKey := DecodeJSON(t, resp, &api.DeployKey{})
unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{
ID: newDeployKey.ID, ID: newDeployKey.ID,
Name: rawKeyBody.Title, Name: rawKeyBody.Title,
Mode: perm.AccessModeWrite, Mode: perm.AccessModeWrite,
@@ -204,3 +205,31 @@ func TestCreateUserKey(t *testing.T) {
fingerprintPublicKeys = DecodeJSON(t, resp, []api.PublicKey{}) fingerprintPublicKeys = DecodeJSON(t, resp, []api.PublicKey{})
assert.Empty(t, fingerprintPublicKeys) 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{}))
}
+22 -25
View File
@@ -8,8 +8,9 @@ import (
"net/url" "net/url"
"testing" "testing"
asymkey_model "gitea.dev/models/asymkey" deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm" "gitea.dev/models/perm"
"gitea.dev/models/user"
"gitea.dev/modules/private" "gitea.dev/modules/private"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -27,7 +28,7 @@ func TestAPIPrivateNoServ(t *testing.T) {
assert.Equal(t, "user2@localhost", key.Name) assert.Equal(t, "user2@localhost", key.Name)
keyContent := "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment" 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) assert.NoError(t, err)
key, user, err = private.ServNoCommand(ctx, deployKey.KeyID) 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", "") results, extra := private.ServCommand(ctx, 1, "user2", "repo1", perm.AccessModeWrite, "git-upload-pack", "")
assert.NoError(t, extra.Error) assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki) assert.False(t, results.IsWiki)
assert.Zero(t, results.DeployKeyID) assert.Empty(t, results.UserExtDoerData)
assert.Equal(t, int64(1), results.KeyID) assert.Equal(t, int64(1), results.PublicKeyID)
assert.Equal(t, "user2@localhost", results.KeyName)
assert.Equal(t, "user2", results.UserName) assert.Equal(t, "user2", results.UserName)
assert.Equal(t, int64(2), results.UserID) assert.Equal(t, int64(2), results.UserID)
assert.Equal(t, "user2", results.OwnerName) 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", "") results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "")
assert.NoError(t, extra.Error) assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki) assert.False(t, results.IsWiki)
assert.Zero(t, results.DeployKeyID) assert.Empty(t, results.UserExtDoerData)
assert.Equal(t, int64(1), results.KeyID) assert.Equal(t, int64(1), results.PublicKeyID)
assert.Equal(t, "user2@localhost", results.KeyName)
assert.Equal(t, "user2", results.UserName) assert.Equal(t, "user2", results.UserName)
assert.Equal(t, int64(2), results.UserID) assert.Equal(t, int64(2), results.UserID)
assert.Equal(t, "user15", results.OwnerName) assert.Equal(t, "user15", results.OwnerName)
@@ -86,18 +85,18 @@ func TestAPIPrivateServ(t *testing.T) {
// Add reading deploy key // Add reading deploy key
testContent := "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment" 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) assert.NoError(t, err)
// Can pull from repo we're a deploy-key for // 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", "") results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "")
assert.NoError(t, extra.Error) assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki) assert.False(t, results.IsWiki)
assert.NotZero(t, results.DeployKeyID) assert.NotEmpty(t, results.UserExtDoerData)
assert.Equal(t, deployKey.KeyID, results.KeyID) assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
assert.Equal(t, "test-deploy", results.KeyName) assert.Equal(t, deployKeyUser.Name, results.UserName)
assert.Equal(t, "user15", results.UserName) assert.Equal(t, deployKeyUser.ID, results.UserID)
assert.Equal(t, int64(15), results.UserID)
assert.Equal(t, "user15", results.OwnerName) assert.Equal(t, "user15", results.OwnerName)
assert.Equal(t, "big_test_private_1", results.RepoName) assert.Equal(t, "big_test_private_1", results.RepoName)
assert.Equal(t, int64(19), results.RepoID) assert.Equal(t, int64(19), results.RepoID)
@@ -119,7 +118,7 @@ func TestAPIPrivateServ(t *testing.T) {
// Add writing deploy key // Add writing deploy key
testContent = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment" 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) assert.NoError(t, err)
// Cannot push to a private repo with reading key // 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", "") results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "")
assert.NoError(t, extra.Error) assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki) assert.False(t, results.IsWiki)
assert.NotZero(t, results.DeployKeyID) assert.NotEmpty(t, results.UserExtDoerData)
assert.Equal(t, deployKey.KeyID, results.KeyID) assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
assert.Equal(t, "test-deploy", results.KeyName) assert.Equal(t, deployKeyUser.Name, results.UserName)
assert.Equal(t, "user15", results.UserName) assert.Equal(t, deployKeyUser.ID, results.UserID)
assert.Equal(t, int64(15), results.UserID)
assert.Equal(t, "user15", results.OwnerName) assert.Equal(t, "user15", results.OwnerName)
assert.Equal(t, "big_test_private_2", results.RepoName) assert.Equal(t, "big_test_private_2", results.RepoName)
assert.Equal(t, int64(20), results.RepoID) 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", "") results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeWrite, "git-upload-pack", "")
assert.NoError(t, extra.Error) assert.NoError(t, extra.Error)
assert.False(t, results.IsWiki) assert.False(t, results.IsWiki)
assert.NotZero(t, results.DeployKeyID) assert.NotEmpty(t, results.UserExtDoerData)
assert.Equal(t, deployKey.KeyID, results.KeyID) assert.Equal(t, deployKey.KeyID, results.PublicKeyID)
assert.Equal(t, "test-deploy", results.KeyName) assert.Equal(t, deployKeyUser.Name, results.UserName)
assert.Equal(t, "user15", results.UserName) assert.Equal(t, deployKeyUser.ID, results.UserID)
assert.Equal(t, int64(15), results.UserID)
assert.Equal(t, "user15", results.OwnerName) assert.Equal(t, "user15", results.OwnerName)
assert.Equal(t, "big_test_private_2", results.RepoName) assert.Equal(t, "big_test_private_2", results.RepoName)
assert.Equal(t, int64(20), results.RepoID) assert.Equal(t, int64(20), results.RepoID)
+76
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)
})
}
+1 -1
View File
@@ -171,7 +171,7 @@ func doSSHLFSAccessTest(_ APITestContext, keyID int64) func(*testing.T) {
_, err := cmd.Output() _, err := cmd.Output()
var errExit *exec.ExitError var errExit *exec.ExitError
require.ErrorAs(t, err, &errExit) // inaccessible, error 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`)
}) })
} }
} }
+2
View File
@@ -17,6 +17,8 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// if it has "toggle" class, it toggles the panel // if it has "toggle" class, it toggles the panel
e.preventDefault(); e.preventDefault();
const sel = el.getAttribute('data-panel')!; 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); const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
for (const elem of elems) { for (const elem of elems) {
if (isElemVisible(elem as HTMLElement)) { if (isElemVisible(elem as HTMLElement)) {
+1 -1
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 // link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
export function initAriaLabels(container: ParentNode) { export function initAriaLabels(container: ParentNode) {
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched'); 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 $> { export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {